Skip to content

Commit ca3de72

Browse files
feat(types)!: SidebarSchema declares what the sidebar node draws; nine unread keys retire, variant takes the registration's enum (objectui#11465) (#11485)
Fixes #11465 Clause-②: yes Dev run for objectui#11465 under the `domain:spec` seat's claim (comment `5955764801`), session `https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC`. It executes triage's key-by-key direction (comment `5954161531`): retire the nine keys the `sidebar` node never draws, give `variant` the renderer's enum, and move the types README example to children composition. `collapsible` was settled by PR objectui#11463 and is untouched. Note on spelling: GitHub strips tag-shaped text from bodies, so generic parameters and placeholders below are written as words. ## The accept-set change, key by key | Key | Both faces before | After | |:--|:--|:--| | `title` | `string` | refused by name: compose a heading `text` node at the start of `children` | | `nav` | a NavLink array | refused by name: navigation is app metadata (the app's `navigation`, objectui#11441), or compose items as `children` | | `content` | a node or a node list | refused by name: move the node(s) into `children` | | `footer` | a node or a node list | refused by name: the last entries of `children` | | `position` | `left` or `right` | refused by name: delete the key | | `defaultCollapsed` | boolean | refused by name: delete the key (the sidebar provider owns the open state) | | `collapsed` | boolean | refused by name: delete the key | | `width` | string or number | refused by name: delete the key; the in-flow form takes a `className` width utility such as `w-72` | | `collapsedWidth` | string or number | refused by name: delete the key (the node never collapses to a narrower width) | | `variant` | `default`, `bordered`, `floating` | `sidebar`, `floating`, `inset` (the registration's enum); `default` and `bordered` refused with `invalid_value` and a message that says to write `sidebar` | - **TypeScript face** (`packages/types/src/navigation.ts`): each retired key is a `?: never` tombstone with a RETIRED docblock, so `tsc` refuses it by name. `variant` is the three-value union with `@default 'sidebar'`. - **zod face** (`packages/types/src/zod/navigation.zod.ts`): each retired key is a `retirementTombstone` built by a local `retiredSidebarKey(key, why, instead)`, whose message reads `RETIRED (objectui#11465, ADR-0049) — KEY on sidebar had no reader: WHY Instead: INSTEAD`. `variant` is `z.enum(['sidebar', 'floating', 'inset'], { error })`. - **Widening:** `variant: 'sidebar'` and `variant: 'inset'` were refused (`invalid_value` at `variant`) on both faces before this change, measured; both validate now. **Narrowing:** the nine keys, and `variant: 'default'` / `'bordered'`, which validated on both faces before, measured. - The renderer is unchanged. Its registration already offered `sidebar` / `floating` / `inset`, and shadcn's `Sidebar` reads `variant` off the spread. ## Zone 2 readings, measured before building **H1 — which keys the renderer reads after PR objectui#11463.** `SidebarNode` reads `schema.children` and destructures `collapsible`; everything else goes through its rest spread onto shadcn's `Sidebar`, which destructures `side`, `variant`, `collapsible`, `className`, `children` and spreads the remainder onto the panel element. A one-off runtime probe (real `SchemaRenderer`, real registry, bare host at desktop width, both `collapsible` forms, one key at a time against its absence; not committed) read: - `title: 'ZZTITLE'` landed as an HTML `title` attribute on the panel element, a native hover tooltip, never a heading; - `nav`, `content`, `footer` landed as attributes whose value is the string `[object Object]`; - `position`, `width` landed as inert attributes; `collapsedWidth` as a lowercase `collapsedwidth` attribute, with React's unknown-prop warning; - `defaultCollapsed`, `collapsed`: markup byte-identical to absence, React warned and dropped them; - controls in the same probe: `side: 'right'` moved the panel to the right edge, and `className: 'w-72'` replaced the width class, so the probe could see a change when one happened. No key on the retire list is read, so all nine retire. ⚠️ `title` is the one whose spread had a user-visible effect (the tooltip). I read that as the rest-spread DOM leak, the same channel that writes `nav="[object Object]"`, not as the node honouring "Sidebar title"; it is flagged in the report for the contract review. **H2 — the retirement convention.** Followed the in-repo precedent: `retirementTombstone()` on the zod face plus `?: never` on the TypeScript face (objectui#11434 PR 1, objectui#10387). For the two retired enum values the precedent is an enum with a named `error` (`object-data-table`'s `drillDown.target`, objectui#10685). ⛔ No bare deletion: `BaseSchema`'s index signature would type it `any`, and `.passthrough()` would let it parse green. **H3 — does `variant` reach shadcn as authored.** Yes, through the real `SchemaRenderer`. On the collapsible form, `sidebar` draws exactly what an absent `variant` draws; `floating` and `inset` each draw differently (padded panel; `floating`'s inner panel takes the rounded, bordered, shadowed classes through `data-variant`). `default` and `bordered` changed only `data-variant`; their classes were `sidebar`'s. On the in-flow form (`collapsible: false`) every value, the three included, draws byte-identical markup: shadcn's not-collapsible branch ignores `variant`. The renderer is not changed. The behaviour is stated in the describe text, the TypeScript doc and the changeset, and pinned (below). **H4 — corpus authors of a retired key or value.** Swept every tracked file for an authored `sidebar` node (`type: 'sidebar'`, the JSON spelling, and `ui:sidebar`) across `examples/**`, `content/docs/**`, every README, every package's tests and fixtures, the designer palettes, and the objectstack checkout. Each hit re-judged: | Document | Authored | Disposition | |:--|:--|:--| | `packages/types/README.md`, "4. Composable" | `nav` | rewritten to children composition (`collapsible: false`, a `text` and a `button` child): the ruling | | `packages/types/examples/dashboard.ts` | `nav` (three links) | the three links become three ghost `button` children; `collapsible: true` kept as authored | | `content/docs/components/basic/sidebar.mdx` and the four `components-basic-sidebar` catalog documents | `collapsible`, `className`, `children` | no change | | `content/docs/components/navigation/header-bar.mdx` | a bare `sidebar` placeholder | no change | | tests (`sidebar-node-provider-10859`, the app-shell one-provider pin, `widget-dom-leak-sweep`, `container-declaration-ratchet`, the cli ratchet) | no retired key | no change | | objectstack | its `type: 'sidebar'` hits are app `navigation` shapes, not this node | no change | No document anywhere authors `variant` on a `sidebar` node. ## Pins - `packages/types/src/__tests__/sidebar-declared-surface-11465.test.ts` - type level (`tsc -p tsconfig.test.json`): each retired key reads as `undefined` on both faces; `variant` is exactly the three values on both faces; `collapsible` keeps its boolean (non-vacuity); a `@ts-expect-error` per retired key and per retired value. - runtime: each retired key, for every former arm, is refused at its own path on the strict face, the tolerant face and the mirror; the message starts with the RETIRED prefix and carries `Instead:` and its prescription; lit controls (the same node without the key parses everywhere; an undeclared key stays green on the tolerant face). - `variant`: the three validate on both faces; `default` and `bordered` are refused at `variant` with `invalid_value`. - README leg: the `sidebar` literal of the README's "4. Composable" fence is extracted and evaluated, never retyped, and validates on both faces; control: the example the README taught before is refused at `nav`. - `packages/components/src/renderers/__tests__/sidebar-variant-registration-11465.test.tsx`: reads the live registration's `variant` enum; every value it offers validates on both faces, and the two retired values are refused; through the real `SchemaRenderer`, the offered values draw pairwise-different markup on the collapsible form, and identical markup on the in-flow form. - The README snippet type-checks: `pnpm check:doc-snippets` compiles the fence against the built `dist`. **Ledger moves:** - `zod-mirror-parity.test.ts`: the arm-count non-vacuity control moves from `SidebarSchema.content` (now a one-arm tombstone) to `SidebarSchema.children`, the channel the node reads. The `NavLinkSchema` row leaves `RECURSION_BREAKING_MIRRORS`: its no-dead-entries case went red naming it, because `SidebarSchema.nav` was the only registered slot that reached it. `NavLink` and `NavLinkSchema` stay exported. - `scripts/markdown-test-inputs.mjs`: the new test is adjudicated as a reader of `packages/types/README.md` (its `--audit` named it as unadjudicated). ## Gates — all at `c95b819e` | Gate | Verdict | |:--|:--| | `pnpm --filter @object-ui/types run type-check` (all three legs; the new test and `examples/dashboard.ts` confirmed in the programs with `--listFilesOnly`) | exit 0 | | `pnpm --filter @object-ui/components run type-check` (the new test confirmed in the program) | exit 0 | | `pnpm exec vitest run packages/types/` | 334 files, 8814 tests passed | | consumer radius: the two new pins, `sidebar-node-provider-10859`, `container-declaration-ratchet`, schema-catalog `sidebar-node-docs-10859` / `safe-validate-corpus-6318` / `component-fixture-declared-keys` / `catalog-gallery-render`, the cli ratchet, the layout one-provider pin, `markdown-test-inputs.test.ts` | 10 files, 733 tests passed | | `pnpm check:doc-snippets` (closure built with its own `--build-filter`) | 776 of 776 blocks judged, 0 failed | | `pnpm check:doc-examples`, `check:doc-types`, `check:doc-fences`, `check:readme-exports` | exit 0 each | | `check-changeset-no-major`, `-presence`, `-fixed`, `-overwrite`; `check:changeset-claims` (report-only: 18 pending changesets name a touched file; each read, none is falsified by this diff) | exit 0 each | | `check:new-line-citations` (0 new), `check:control-bytes` | exit 0 each | | `check:component-surface-parity` (report-only) | the nine `ui:sidebar` interface-extra-key rows leave the census; `read-of-tombstoned-key` stays 0 | | `check:handler-key-reads`, `check-prompt-component-keys`, `check-test-path-roots`, `check-type-check-coverage`, `check:unreferenced-sources`, `check:published-dist`, `check-doc-expression-carriage`, `check-lint-coverage`, `markdown-test-inputs --audit` | exit 0 each | | `check:sdui-registration-pins` | NOT MEASURED: exit 2, no console build to weigh; this diff moves no registration input, so it is not owed here | **Lint, narrowed and declared.** `eslint --no-inline-config --format json` over the seven changed lintable files: 7 files, 0 errors (2 warnings, both on unchanged `zod-mirror-parity.test.ts` lines whose inline disable `--no-inline-config` ignores). The `**/*.{ts,tsx}` block of `eslint.config.js` covers six of them and the `.mjs` is covered too; the config sets no `parserOptions.project` or `projectService`, so linting is not type-aware and this diff cannot move a verdict in an untouched file. The repo-wide `pnpm lint` and the full test farm are CI's. ## Ablation and reverse verification Each leg ran through objectstack's `scripts/ablation-replace.mjs` in wrap mode on the committed tree: the mutation proven on disk (anchor count 1 to 0, blob changed), the restore proven (blob equals HEAD, `git diff HEAD` empty). vitest resolves `@object-ui/types` to source through the root alias, so the vitest legs need no rebuild. 1. zod `variant` enum back to `default` / `bordered` / `floating`: 7 failed (four in the types pin, three in the components pin). 2. zod `title` accepting a string again: 2 failed (the refusal-path and message cases for `title`). 3. TypeScript `title?: string` again: `tsc -p tsconfig.test.json` red with TS2344 (the `Equal` row), TS2578 (the unused `@ts-expect-error`) and TS2322 in `zod-mirror-parity.test.ts`'s drift ledger. 4. The README's old `nav` line put back into the fence: `pnpm check:doc-snippets` judged 1 block failed, TS2322 at the README's `nav` line (a NavLink-shaped array is not assignable to `undefined`), which proves the gate read the rebuilt `.d.ts`. The first attempt was a no-op (my anchor was a prefix of its replacement, and the tool refused to count it as landed); it was re-run with a non-prefix anchor. ## Acceptance notes (observations, not filed) - **`side`:** the registration offers `side` (`left` / `right`) and shadcn honours it, but `SidebarSchema` does not declare it, so the strict face refuses `side: 'right'` (`unrecognized_keys`) while the render honours it. `check:component-surface-parity` already reports it (`ui:sidebar.side`, input-outside-keyset). The `position` tombstone deliberately does not point at `side`. - **`variant` on the in-flow form** draws nothing (H3); not changed, by the ruling. - **`NavLink` / `NavLinkSchema`** stay exported but no longer type any declared member; whether they retire too is an open question in the report, outside the claimed file surface. - **The rest spread:** `SidebarNode` forwards every unread key onto the panel element, which is how `[object Object]` attributes arose. After this change the validator refuses the nine; the renderer is untouched (⛔ no consumer-side narrowing). - `content/docs/components/basic/sidebar.mdx` does not document `variant`; its sentence on what the renderer reads is not made false by this change, and it is outside the claimed surface. - **Overlap:** `main` was merged at `2c0ddf226` (PR objectui#11477 edits other blocks of the types README and the imports of `examples/dashboard.ts`); this diff's hunks there are the `sidebar` literals only. --- _Generated by [Claude Code](https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 50c73fe commit ca3de72

9 files changed

Lines changed: 607 additions & 46 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@object-ui/types': minor
3+
---
4+
5+
feat(types)!: `SidebarSchema` declares what the `sidebar` node draws — nine unread keys are retired on both faces, and `variant` takes the registration's enum (objectui#11465)
6+
7+
**BREAKING (authoring):** nine members of `SidebarSchema` are retired on both faces (ADR-0049): `title`, `nav`, `content`, `footer`, `position`, `defaultCollapsed`, `collapsed`, `width` and `collapsedWidth`. Each is now a `?: never` tombstone on the TypeScript face, so `tsc` refuses it by name, and the zod mirror refuses it by name with the reason and what to write instead, so `safeValidateSchema`, the strict authoring face and `objectui validate` refuse it. Before this change both faces accepted all nine. The `sidebar` node never read any of them: it draws its `children`, reads `collapsible` and `variant`, and passes `className` to the sidebar element. The rest of the node's keys reached the sidebar panel as HTML attributes (`title` as a hover tooltip, the lists and nodes as `[object Object]`), or React dropped them with a warning. The renderer is unchanged.
8+
9+
**`variant`** is now `'sidebar' | 'floating' | 'inset'` on both faces, the three shadcn values the `sidebar` registration offers; `'sidebar'` is the default. Before, both faces declared `'default' | 'bordered' | 'floating'`, so `objectui validate` refused `'sidebar'` and `'inset'`, which the registration offers, and accepted `'default'` and `'bordered'`, which drew exactly what `'sidebar'` draws. Those two are refused now, with no alias window. A variant shows on the collapsible form only: with `collapsible: false` every value draws the same in-flow column.
10+
11+
**Clause-②: yes** — `variant` gains `'sidebar'` and `'inset'` (widening) and loses `'default'` and `'bordered'`, and nine members narrow to `never` (narrowing). Scored `minor`, not `major`: this repository scores its own breaking changes `minor` and spells the breaking meaning out in the body.
12+
13+
**Migration — one line per key.**
14+
15+
- `title`: compose the heading as a `text` node at the start of `children`.
16+
- `nav`: navigation is application metadata (objectui#11441). Put the links in the app document's `navigation`, which the app shell's sidebar draws; to draw items inside the node, compose them as `children` (for example `button` nodes with `variant: "ghost"`).
17+
- `content`: move the node or nodes into `children`.
18+
- `footer`: compose it as the last entries of `children`.
19+
- `position`, `defaultCollapsed`, `collapsed`, `collapsedWidth`: delete the key. The open state belongs to the sidebar provider (the host's, or the one the node mounts when there is none), and the node never collapses to a narrower width.
20+
- `width`: delete the key. The in-flow form (`collapsible: false`) takes a width utility in `className`, for example `w-72`.
21+
- `variant: 'default'` or `variant: 'bordered'`: write `variant: 'sidebar'`, or delete the key.
22+
23+
```ts
24+
// before: type-checked and validated, and the list drew nothing
25+
const sidebar: SidebarSchema = { type: 'sidebar', nav: [{ label: 'Home', href: '/' }] };
26+
27+
// after: the node draws its children
28+
const sidebar: SidebarSchema = {
29+
type: 'sidebar',
30+
collapsible: false,
31+
children: [{ type: 'button', label: 'Home', variant: 'ghost' }],
32+
};
33+
```
34+
35+
The types README's composition example and `examples/dashboard.ts` now author the `sidebar` node this way. `NavLink` and `NavLinkSchema` stay exported.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#11465 — `SidebarSchema.variant` takes the `sidebar` registration's
11+
* own enum, held against the LIVE registration rather than a copied list.
12+
*
13+
* Before this card the two disagreed: the registration offered shadcn's
14+
* `sidebar` / `floating` / `inset`, while both `@object-ui/types` faces
15+
* declared `default` / `bordered` / `floating`. So `objectui validate` refused
16+
* two of the three values the registration offers, and accepted two values
17+
* shadcn does not know (they drew exactly what `sidebar` draws). This file
18+
* reads the registration's `variant` input and asks both validator faces
19+
* about every value in it, so a registration that grows a value the schema
20+
* does not take turns this red.
21+
*
22+
* The render half holds what the declaration says about the three values,
23+
* measured through the real `SchemaRenderer` and registry: on the collapsible
24+
* form each draws differently, and on the in-flow form (`collapsible: false`)
25+
* shadcn ignores `variant`, so every value draws the same column.
26+
*/
27+
28+
import { describe, it, expect, afterEach } from 'vitest';
29+
import { render, cleanup } from '@testing-library/react';
30+
import React from 'react';
31+
import { ComponentRegistry } from '@object-ui/core';
32+
import { SchemaRenderer } from '@object-ui/react';
33+
import { safeValidateSchema, StrictAnyComponentSchema } from '@object-ui/types/zod';
34+
import '../../renderers';
35+
36+
afterEach(cleanup);
37+
38+
const ITEMS = [{ type: 'text', content: 'Dashboard' }];
39+
40+
type Parse = { success: boolean; error?: { issues: { path: PropertyKey[] }[] } };
41+
const strict = StrictAnyComponentSchema as unknown as { safeParse: (v: unknown) => Parse };
42+
const refusedAt = (result: Parse) => (result.success ? [] : (result.error?.issues ?? []).map((i) => i.path.join('.')));
43+
44+
/** The `variant` enum the live `sidebar` registration offers. */
45+
function registeredVariants(): string[] {
46+
const inputs = (ComponentRegistry.getMeta('sidebar')?.inputs ?? []) as { name: string; enum?: unknown[] }[];
47+
const variant = inputs.find((i) => i.name === 'variant');
48+
if (!variant || !Array.isArray(variant.enum)) throw new Error('the `sidebar` registration offers no `variant` enum');
49+
return variant.enum.map(String);
50+
}
51+
52+
/** The markup a node draws in a bare host (the node mounts its own provider there). */
53+
const markup = (node: object) => {
54+
const { container } = render(<SchemaRenderer schema={node as never} />);
55+
const html = container.innerHTML;
56+
cleanup();
57+
return html;
58+
};
59+
60+
describe('`SidebarSchema.variant` is the registration\'s enum (objectui#11465)', () => {
61+
it('the registration offers a non-empty enum, without the two retired values', () => {
62+
const offered = registeredVariants();
63+
expect(offered.length).toBeGreaterThan(0);
64+
expect(offered).not.toContain('default');
65+
expect(offered).not.toContain('bordered');
66+
});
67+
68+
it('every value the registration offers validates on the strict face and the tolerant face', () => {
69+
const refused = registeredVariants().flatMap((variant) => {
70+
const node = { type: 'sidebar', variant, children: ITEMS };
71+
return [
72+
...refusedAt(strict.safeParse(node)).map((p) => `strict ${variant} @${p}`),
73+
...refusedAt(safeValidateSchema(node) as Parse).map((p) => `tolerant ${variant} @${p}`),
74+
];
75+
});
76+
expect(refused).toEqual([]);
77+
});
78+
79+
it.each(['default', 'bordered'])('the retired `%s` is refused at `variant` on both faces', (variant) => {
80+
const node = { type: 'sidebar', variant, children: ITEMS };
81+
expect(refusedAt(strict.safeParse(node))).toEqual(['variant']);
82+
expect(refusedAt(safeValidateSchema(node) as Parse)).toEqual(['variant']);
83+
});
84+
});
85+
86+
describe('what each variant draws, through the real SchemaRenderer (objectui#11465)', () => {
87+
it('on the collapsible form, every offered value draws differently, and `sidebar` is the default', () => {
88+
const base = { type: 'sidebar', children: ITEMS };
89+
const drawn = registeredVariants().map((variant) => markup({ ...base, variant }));
90+
expect(new Set(drawn).size).toBe(drawn.length);
91+
expect(markup({ ...base, variant: 'sidebar' })).toBe(markup(base));
92+
});
93+
94+
it('on the in-flow form (`collapsible: false`), every value draws the same column', () => {
95+
const base = { type: 'sidebar', collapsible: false, children: ITEMS };
96+
const absent = markup(base);
97+
for (const variant of registeredVariants()) {
98+
expect(markup({ ...base, variant }), variant).toBe(absent);
99+
}
100+
});
101+
});

‎packages/types/README.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,9 +417,16 @@ import type { ContainerSchema, FlexBlockNode, HeaderBarSchema, SidebarSchema } f
417417

418418
// The two leaves are annotated so the nesting below is checked against the
419419
// shipped types rather than absorbed by `BaseSchema`'s index signature.
420+
// The sidebar draws what it composes through `children` (an app's navigation
421+
// lives in the app's metadata, not on this node), and `collapsible: false`
422+
// draws it in the page flow, beside `main`.
420423
const sidebar: SidebarSchema = {
421424
type: 'sidebar',
422-
nav: [{ label: 'Home', href: '/' }]
425+
collapsible: false,
426+
children: [
427+
{ type: 'text', content: 'Menu' },
428+
{ type: 'button', label: 'Home', variant: 'ghost' }
429+
]
423430
};
424431

425432
const main: ContainerSchema = {

‎packages/types/examples/dashboard.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,15 @@ export const dashboardSchema: FlexBlockNode = {
4747
properties: {
4848
direction: 'row',
4949
children: [
50-
// Sidebar
50+
// Sidebar: it draws what it composes through `children`; an app's
51+
// navigation lives in the app's metadata, not on this node.
5152
{
5253
type: 'sidebar',
5354
collapsible: true,
54-
nav: [
55-
{ label: 'Dashboard', href: '/', icon: 'Home', active: true },
56-
{ label: 'Users', href: '/users', icon: 'Users' },
57-
{ label: 'Settings', href: '/settings', icon: 'Settings' }
55+
children: [
56+
{ type: 'button', label: 'Dashboard', variant: 'ghost', icon: 'Home' },
57+
{ type: 'button', label: 'Users', variant: 'ghost', icon: 'Users' },
58+
{ type: 'button', label: 'Settings', variant: 'ghost', icon: 'Settings' }
5859
]
5960
} as SidebarSchema,
6061

0 commit comments

Comments
 (0)