From bc92486f4cba6517abde7102a103b1850aabea7a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 16:30:16 +0000 Subject: [PATCH 1/4] docs(components): publish button-group's shipped surface, both directions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `content/docs/components/basic/button-group.mdx` taught ten rows that disagreed with `packages/types/src/navigation.ts` and its Zod mirror. Over-stated (documented, never declared): `ButtonGroupSchema.value`, `ButtonGroupSchema.selectionMode`, `ButtonGroupButton.value`, `ButtonGroupButton.icon`; `buttons` spelled required against a declared `buttons?`; `label?` spelled optional against a declared required `label`. Under-stated (declared, never documented): `ButtonGroupButton.variant`, `.size`, `.onClick`, `.className`; three `variant` members (`secondary`, `destructive`, `link`) and one `size` member (`icon`). The `## Selection Mode` section is deleted: the renderer implements no selection behaviour at all, so the heading promised a capability nothing draws. Its two catalog fixtures stay — a separate verification population. `button-group-doc-surface-6347.test.ts` pins the corrections. No gate reads a `plaintext` fence, so without it a green CI run says only that nothing else broke. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .../docs/components/basic/button-group.mdx | 31 +- .../button-group-doc-surface-6347.test.ts | 278 ++++++++++++++++++ 2 files changed, 291 insertions(+), 18 deletions(-) create mode 100644 packages/types/src/__tests__/button-group-doc-surface-6347.test.ts diff --git a/content/docs/components/basic/button-group.mdx b/content/docs/components/basic/button-group.mdx index 44d814ab63..fb5bbffeb6 100644 --- a/content/docs/components/basic/button-group.mdx +++ b/content/docs/components/basic/button-group.mdx @@ -18,34 +18,29 @@ The Button Group component groups multiple buttons together with consistent styl -## Selection Mode - - - - - - ## Schema ```plaintext interface ButtonGroupButton { - label?: string; - value: string; - icon?: string; - disabled?: boolean; + label: string; // Button label (required) + variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'; + size?: 'default' | 'sm' | 'lg' | 'icon'; + disabled?: boolean; // Whether this button is disabled + onClick?: () => void; // Runtime handler; not authorable in JSON + className?: string; // Per-button CSS class } interface ButtonGroupSchema { type: 'button-group'; - buttons: ButtonGroupButton[]; // Button definitions - value?: string | string[]; // Selected value(s) - selectionMode?: 'single' | 'multiple' | 'none'; - variant?: 'default' | 'outline' | 'ghost'; - size?: 'sm' | 'default' | 'lg'; - + buttons?: ButtonGroupButton[]; // Button definitions + + // Defaults applied to every button that does not set its own + variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'; + size?: 'default' | 'sm' | 'lg' | 'icon'; + // States disabled?: boolean; - + // Styling className?: string; } diff --git a/packages/types/src/__tests__/button-group-doc-surface-6347.test.ts b/packages/types/src/__tests__/button-group-doc-surface-6347.test.ts new file mode 100644 index 0000000000..eec6142698 --- /dev/null +++ b/packages/types/src/__tests__/button-group-doc-surface-6347.test.ts @@ -0,0 +1,278 @@ +/** + * 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. + */ + +/** + * `button-group.mdx` publishes the shipped surface, in BOTH directions (objectui#6347). + * + * ## Why this file exists rather than "the docs gates went green" + * + * Nothing in CI reads a component page's `plaintext` interface fence. + * `check-doc-component-types` reads the `type` STRING LITERALS out of docs code + * blocks and asks only "does something register this type"; + * `check-doc-snippet-types` compiles `ts`/`tsx` fences and a `plaintext` fence is + * not one. So a member row in that fence can name any key at all and every gate + * stays green — the same hole `component-fixture-declared-keys.test.ts` records + * for catalog fixtures, one surface over. A green CI run on the docs correction + * this file accompanies would have meant "nothing else broke", NOT "the + * correction is right". + * + * ## What the page taught before, measured on this tree + * + * Ten rows disagreed with `packages/types/src/navigation.ts` and its Zod mirror, + * in BOTH directions — the card had noticed only the first direction: + * + * OVER-stated (documented, never declared): `ButtonGroupSchema.value`, + * `ButtonGroupSchema.selectionMode`, `ButtonGroupButton.value`, + * `ButtonGroupButton.icon`; `buttons` spelled REQUIRED against a declared + * `buttons?`; `label?` spelled optional against a declared required `label`. + * UNDER-stated (declared, never documented): `ButtonGroupButton.variant`, + * `.size`, `.onClick`, `.className`; three `variant` members + * (`secondary`, `destructive`, `link`) and one `size` member (`icon`). + * + * Both directions are one defect class with the sign flipped, which is why this + * file asserts SET EQUALITY on the component's own members rather than the + * subset the card enumerated. A one-directional pin is what let the omissions + * sit beside the corrections through two earlier passes over these pages. + * + * ## The authority is the mirror's `.shape`, never parse acceptance + * + * `BaseSchema` is `.passthrough()` and carries `[key: string]: any`, so an + * undeclared `selectionMode` PARSES GREEN and type-checks — acceptance cannot + * tell "declared" from "admitted unexamined" (the reading + * `undeclared-but-consumed-keys-6150.test.ts` and `object-grid-title-mirrored` + * established). Membership is therefore read off `.shape`, optionality off each + * member's own `safeParse(undefined)`, and the enum vocabularies through the + * shared `enumOptions` reader rather than a hand-copied string list, so this + * file follows the platform instead of asserting yesterday's vocabulary. + * + * ## Two BaseSchema-inherited rows are pinned for MEMBERSHIP only — deliberate + * + * The page also documents `disabled` and `className`, which `ButtonGroupSchema` + * inherits. Their type TEXT is not pinned here: `BaseSchema` declares + * `disabled?: boolean | string` (the string limb is the expression dialect), + * while this page — and all 21 sibling `content/docs/components/**` pages that + * document it, and the common-props table in `content/docs/api/schema-reference.md` + * — spell it `boolean`. Correcting one page would make it the lone outlier of a + * repo-wide convention, so it is measured and reported rather than guessed at. + * + * ## `## Selection Mode` is gone because the renderer cannot draw it + * + * `packages/components/src/renderers/basic/button-group.tsx` implements no + * selection behaviour at all — it maps `schema.buttons` to `Button` elements and + * reads `variant` / `size` / `className` / `label` only. The section's heading + * promised a capability nothing implements. The renderer half is asserted here + * too, so that whoever DOES implement selection sees this file go red and knows + * the page owes a section again; without it, the deletion would read as a + * permanent verdict rather than a statement about this tree. + * + * The two catalog fixtures the section used to render STAY (they are a different + * verification population, fenced off by PR #6345) and are asserted present. + */ + +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { enumOptions } from '@object-ui/test-support'; + +import { BaseSchema } from '../zod/base.zod'; +import { ButtonGroupButtonSchema, ButtonGroupSchema } from '../zod/navigation.zod'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); + +const DOC_PATH = 'content/docs/components/basic/button-group.mdx'; +const RENDERER_PATH = 'packages/components/src/renderers/basic/button-group.tsx'; +const FIXTURE_DIR = 'examples/schema-catalog/src/schemas/components-basic-button-group'; + +const read = (relative: string): string => readFileSync(join(REPO_ROOT, relative), 'utf8'); + +const doc = read(DOC_PATH); +const renderer = read(RENDERER_PATH); + +/* ── The documented surface, parsed out of the page's `plaintext` fence ──── */ + +interface DocumentedMember { + /** Spelled with a `?`. */ + readonly optional: boolean; + /** Everything between the colon and the terminating semicolon, trimmed. */ + readonly typeText: string; +} + +/** The one `plaintext` fence that carries the page's interface blocks. */ +function schemaFence(): string { + const fences = [...doc.matchAll(/```plaintext\n([\s\S]*?)```/g)].map((match) => match[1]); + if (fences.length !== 1) { + throw new Error(`expected exactly one \`plaintext\` fence in ${DOC_PATH}, found ${fences.length}`); + } + return fences[0]; +} + +/** The body of one `interface { … }` block, THROWING when absent. */ +function interfaceBody(source: string, name: string): string { + const opener = `interface ${name} {`; + const start = source.indexOf(opener); + if (start === -1) throw new Error(`no \`${opener}\` block in ${DOC_PATH}`); + const end = source.indexOf('\n}', start); + if (end === -1) throw new Error(`unterminated \`${opener}\` block in ${DOC_PATH}`); + return source.slice(start + opener.length, end); +} + +/** Member rows of an interface body, keyed by name. */ +function documentedMembers(body: string): Map { + const members = new Map(); + for (const match of body.matchAll(/^ {2}(\w+)(\?)?:\s*([^;]+);/gm)) { + members.set(match[1], { optional: match[2] === '?', typeText: match[3].trim() }); + } + return members; +} + +/** The single-quoted literals in a documented union, in source order. */ +const documentedLiterals = (typeText: string): string[] => + [...typeText.matchAll(/'([^']*)'/g)].map((match) => match[1]); + +/* ── The declared surface, read off the shipped Zod mirrors ──────────────── */ + +type ZodShape = Record; + +const buttonShape = ButtonGroupButtonSchema.shape as unknown as ZodShape; +const groupShape = ButtonGroupSchema.shape as unknown as ZodShape; +const BASE_KEYS = new Set(Object.keys(BaseSchema.shape)); + +/** A member accepts `undefined`, i.e. the page should spell it with a `?`. */ +const declaredOptional = (shape: ZodShape, key: string): boolean => + shape[key].safeParse(undefined).success; + +/** + * The members this component declares in its OWN right. + * + * `type` is re-declared by `.extend()` as the discriminator literal, so it + * belongs here even though `BaseSchema` carries a `type` too; every other + * inherited key is documented centrally, not on a component page. + */ +const groupOwnKeys = Object.keys(groupShape).filter((key) => key === 'type' || !BASE_KEYS.has(key)); + +const buttonBody = interfaceBody(schemaFence(), 'ButtonGroupButton'); +const groupBody = interfaceBody(schemaFence(), 'ButtonGroupSchema'); +const buttonDoc = documentedMembers(buttonBody); +const groupDoc = documentedMembers(groupBody); + +describe('button-group.mdx: the `ButtonGroupButton` block IS the shipped mirror (objectui#6347)', () => { + it('documents exactly the declared members — no more, no fewer', () => { + expect([...buttonDoc.keys()].sort()).toEqual(Object.keys(buttonShape).sort()); + }); + + it.each([...Object.keys(buttonShape)])('spells `%s` with the declared optionality', (key) => { + expect(buttonDoc.get(key)?.optional).toBe(declaredOptional(buttonShape, key)); + }); + + it.each(['variant', 'size'])('publishes the whole shipped `%s` vocabulary', (key) => { + const shipped = enumOptions(buttonShape[key]); + expect(shipped.length).toBeGreaterThan(0); + expect(documentedLiterals(buttonDoc.get(key)?.typeText ?? '')).toEqual(shipped); + }); + + it('documents `onClick` as a runtime slot, not something a JSON author supplies', () => { + // `onClick` is `() => void` / `z.function()`; objectui#4453 narrowed the + // runtime to `typeof === 'function'`, so an authored object is dropped. + expect(buttonDoc.get('onClick')?.typeText).toBe('() => void'); + expect(buttonBody).toContain('not authorable in JSON'); + }); +}); + +describe('button-group.mdx: the `ButtonGroupSchema` block IS the shipped mirror (objectui#6347)', () => { + it('names no member the mirror does not declare', () => { + const undeclared = [...groupDoc.keys()].filter((key) => !(key in groupShape)); + expect(undeclared).toEqual([]); + }); + + it('documents every member this component declares in its own right', () => { + const missing = groupOwnKeys.filter((key) => !groupDoc.has(key)); + expect(missing).toEqual([]); + // Non-vacuity: the filter above is only worth something while it has + // something to filter. + expect([...groupOwnKeys].sort()).toEqual(['buttons', 'size', 'type', 'variant']); + }); + + it.each([...Object.keys(groupShape).filter((key) => !BASE_KEYS.has(key))])( + 'spells `%s` with the declared optionality', + (key) => { + expect(groupDoc.get(key)?.optional).toBe(declaredOptional(groupShape, key)); + }, + ); + + it.each(['variant', 'size'])('publishes the whole shipped `%s` vocabulary', (key) => { + const shipped = enumOptions(groupShape[key]); + expect(shipped.length).toBeGreaterThan(0); + expect(documentedLiterals(groupDoc.get(key)?.typeText ?? '')).toEqual(shipped); + }); + + it.each(['disabled', 'className'])( + 'the inherited `%s` row it documents is a declared member (membership only — see header)', + (key) => { + expect(BASE_KEYS.has(key)).toBe(true); + expect(groupDoc.get(key)?.optional).toBe(declaredOptional(groupShape, key)); + }, + ); +}); + +describe('button-group.mdx: no Selection Mode section, because the renderer has no selection (objectui#6347)', () => { + it('the renderer reads neither `selectionMode` nor a group-level `value`', () => { + expect(renderer).not.toMatch(/selectionMode/); + expect(renderer).not.toMatch(/schema\.value/); + // Control: this IS the renderer, and the scan can find things in it. + expect(renderer).toContain('schema.buttons'); + expect(renderer).toContain("ComponentRegistry.register('button-group'"); + }); + + it('the page carries no heading promising selection', () => { + expect(doc).not.toMatch(/^#+ .*Selection/m); + expect(doc).not.toContain('single-selection'); + expect(doc).not.toContain('multiple-selection'); + }); + + it('the two catalog fixtures it used to render are still on disk', () => { + // Removing the section orphans them from this page; catalog entries are a + // separate verification population and are NOT deleted to tidy that up. + for (const slug of ['single-selection', 'multiple-selection']) { + expect(read(`${FIXTURE_DIR}/${slug}.json`)).toContain('"type": "button-group"'); + } + }); +}); + +describe('counter-probes: the readers above can still fail (objectui#6347)', () => { + it('`interfaceBody` throws rather than returning an empty body', () => { + expect(() => interfaceBody(schemaFence(), 'ButtonGroupSelectionSchema')).toThrow( + /no `interface ButtonGroupSelectionSchema \{` block/, + ); + }); + + it('the extractor really parsed rows, not an empty match set', () => { + expect(buttonDoc.size).toBe(Object.keys(buttonShape).length); + expect(groupDoc.size).toBeGreaterThanOrEqual(groupOwnKeys.length); + }); + + it('an undeclared row would be caught — the shape the card reported', () => { + const regressed = documentedMembers( + " buttons?: ButtonGroupButton[];\n selectionMode?: 'single' | 'multiple' | 'none';", + ); + expect([...regressed.keys()].filter((key) => !(key in groupShape))).toEqual(['selectionMode']); + }); + + it('an omitted declared row would be caught — the direction the card missed', () => { + const regressed = documentedMembers(' type: string;\n buttons?: ButtonGroupButton[];'); + expect(groupOwnKeys.filter((key) => !regressed.has(key))).toEqual(['variant', 'size']); + }); + + it('a truncated enum row would be caught', () => { + expect(documentedLiterals("'default' | 'outline' | 'ghost'")).not.toEqual( + enumOptions(groupShape.variant), + ); + }); +}); From 2ced94e14d58dde9aabf49a938724c7e3fdfc792 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 16:37:36 +0000 Subject: [PATCH 2/4] docs: changeset for the button-group schema-block correction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty frontmatter: the diff publishes nothing. `content/docs/**` belongs to `@object-ui/site` (in the changeset `ignore` list) and the pin lives in `packages/types/src/__tests__/`, which `packages/types/tsconfig.json` excludes from the build — `packages/types/dist` is byte-identical to `main`'s. Same form as the two sibling corrections to these pages, `.changeset/6132-*` and `.changeset/6143-*`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .changeset/6347-button-group-schema-block.md | 69 ++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .changeset/6347-button-group-schema-block.md diff --git a/.changeset/6347-button-group-schema-block.md b/.changeset/6347-button-group-schema-block.md new file mode 100644 index 0000000000..b33374fe92 --- /dev/null +++ b/.changeset/6347-button-group-schema-block.md @@ -0,0 +1,69 @@ +--- +--- + +Docs only, publishes nothing: `content/docs/components/basic/button-group.mdx` +published a `ButtonGroupSchema` / `ButtonGroupButton` surface that disagreed +with the shipped types in **both** directions. objectui#6347 named the two +over-statements; re-measuring the whole fence against +`packages/types/src/navigation.ts` and its Zod mirror found ten rows. + +Over-stated — documented, never declared: + +| page before | shipped declaration | page after | +| --- | --- | --- | +| `ButtonGroupSchema.value?: string \| string[]` | not declared (`navigation.ts:335-351`, `zod/navigation.zod.ts:154-159`) | removed | +| `ButtonGroupSchema.selectionMode?: 'single' \| 'multiple' \| 'none'` | not declared (same two blocks) | removed | +| `ButtonGroupButton.value: string` (required) | not declared (`navigation.ts:305-330`, `zod/navigation.zod.ts:142-149`) | removed | +| `ButtonGroupButton.icon?: string` | not declared (same two blocks) | removed | +| `buttons: ButtonGroupButton[]` (required) | `buttons?: ButtonGroupButton[]` (`navigation.ts:340`, `.zod.ts:156`) | `buttons?:` | +| `label?: string` | `label: string` — required (`navigation.ts:309`, `.zod.ts:143`) | `label:` | + +Under-stated — declared, never documented: + +| page before | shipped declaration | page after | +| --- | --- | --- | +| `ButtonGroupButton` had no `variant` row | `variant?: 'default' \| 'secondary' \| 'destructive' \| 'outline' \| 'ghost' \| 'link'` (`navigation.ts:313`, `.zod.ts:144`) | added | +| `ButtonGroupButton` had no `size` row | `size?: 'default' \| 'sm' \| 'lg' \| 'icon'` (`navigation.ts:317`, `.zod.ts:145`) | added | +| `ButtonGroupButton` had no `onClick` row | `onClick?: () => void` (`navigation.ts:325`, `.zod.ts:147`) | added, annotated as a runtime slot | +| `ButtonGroupButton` had no `className` row | `className?: string` (`navigation.ts:329`, `.zod.ts:148`) | added | +| `ButtonGroupSchema.variant?: 'default' \| 'outline' \| 'ghost'` | six members (`navigation.ts:345`, `.zod.ts:157`) | `secondary`, `destructive`, `link` restored | +| `ButtonGroupSchema.size?: 'sm' \| 'default' \| 'lg'` | four members (`navigation.ts:350`, `.zod.ts:158`) | `icon` restored | + +Both directions are one defect class with the sign flipped. Fixing only the +direction a card happens to notice is what left these omissions sitting beside +two earlier corrections to the same page (objectui#6132, objectui#6143). + +`onClick` is documented as declared and annotated as a runtime slot: it is +`z.function()`, and objectui#4453 narrowed the runtime to +`typeof === 'function'`, so a JSON author cannot supply one. + +**`## Selection Mode` is deleted, not softened.** +`packages/components/src/renderers/basic/button-group.tsx` implements no +selection behaviour at all — no `selectionMode` read, no group-level `value` +read, no state — and it does not wire the declared per-button `onClick` either. +It maps `schema.buttons` to `Button` elements reading `variant`, `size`, +`className` and `label` only. A heading over two inert demos taught a +capability nothing draws. The capability question itself is filed separately +rather than answered here. + +The two catalog fixtures the section rendered +(`components-basic-button-group/single-selection`, `/multiple-selection`) are +**kept**. Catalog entries are a separate verification population, fenced off by +PR #6345; orphaning them from this page is the accepted cost, and no gate reads +a catalog entry's page references (the index is generated from the schema +directory by `scripts/regenerate-catalog-index.py`, path-keyed). + +`packages/types/src/__tests__/button-group-doc-surface-6347.test.ts` pins the +result. Nothing in CI parses a `plaintext` fence — `check:doc-types` reads only +the `type` string literals and `check:doc-snippets` compiles `ts`/`tsx` fences — +so without it a green CI run would have said "nothing else broke", not "the +correction is right". The pin reads membership off the mirror's `.shape` rather +than off parse acceptance, because `BaseSchema` is `.passthrough()` and carries +`[key: string]: any`: an undeclared `selectionMode` parses green and +type-checks, so acceptance cannot tell "declared" from "admitted unexamined". + +No type was minted, no fence moved (the page holds one `plaintext` fence before +and after, so objectui#5867's shrink-only declared population is unchanged), and +no catalog fixture was edited. + +Part of objectui#6347. From 467acdf0cea4722b93e7629e5117268bc5f4bed4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 16:43:30 +0000 Subject: [PATCH 3/4] =?UTF-8?q?docs(components):=20the=20eleventh=20row=20?= =?UTF-8?q?=E2=80=94=20button-group=20inherits=20`disabled:=20boolean=20|?= =?UTF-8?q?=20string`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ButtonGroupSchema` is the ONE of fourteen component schemas documenting a schema-level `disabled` that does not redeclare it: the other thirteen narrow it to `disabled?: boolean` themselves, so their pages are right and this one was under-stating `BaseSchema`'s declared `boolean | string`. Measured before acting, because the first reading looked like a repo-wide convention worth leaving alone. It is a single-page divergence. The pin now asserts the type TEXT of both inherited rows against the mirror instead of their membership only, with its own union reader rather than a misuse of `enumOptions` (whose contract is enum NAMES, not option schemas). Whether `ButtonGroupSchema` should narrow `disabled` like its thirteen siblings is a types question and is not answered here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .changeset/6347-button-group-schema-block.md | 15 +++- .../docs/components/basic/button-group.mdx | 2 +- .../button-group-doc-surface-6347.test.ts | 74 +++++++++++++++++-- 3 files changed, 81 insertions(+), 10 deletions(-) diff --git a/.changeset/6347-button-group-schema-block.md b/.changeset/6347-button-group-schema-block.md index b33374fe92..9404a64884 100644 --- a/.changeset/6347-button-group-schema-block.md +++ b/.changeset/6347-button-group-schema-block.md @@ -5,7 +5,8 @@ Docs only, publishes nothing: `content/docs/components/basic/button-group.mdx` published a `ButtonGroupSchema` / `ButtonGroupButton` surface that disagreed with the shipped types in **both** directions. objectui#6347 named the two over-statements; re-measuring the whole fence against -`packages/types/src/navigation.ts` and its Zod mirror found ten rows. +`packages/types/src/navigation.ts`, `packages/types/src/base.ts` and their Zod +mirrors found eleven rows. Over-stated — documented, never declared: @@ -28,11 +29,23 @@ Under-stated — declared, never documented: | `ButtonGroupButton` had no `className` row | `className?: string` (`navigation.ts:329`, `.zod.ts:148`) | added | | `ButtonGroupSchema.variant?: 'default' \| 'outline' \| 'ghost'` | six members (`navigation.ts:345`, `.zod.ts:157`) | `secondary`, `destructive`, `link` restored | | `ButtonGroupSchema.size?: 'sm' \| 'default' \| 'lg'` | four members (`navigation.ts:350`, `.zod.ts:158`) | `icon` restored | +| `disabled?: boolean` | `BaseSchema.disabled?: boolean \| string` (`base.ts`, `zod/base.zod.ts:190`) — `ButtonGroupSchema` does **not** redeclare it | `boolean \| string` | Both directions are one defect class with the sign flipped. Fixing only the direction a card happens to notice is what left these omissions sitting beside two earlier corrections to the same page (objectui#6132, objectui#6143). +The `disabled` row deserves its own note, because it looks like a convention and +is not. Fourteen `content/docs/components/**` pages spell a component schema's +own `disabled` as `boolean` — but **thirteen of those fourteen schemas redeclare +`disabled?: boolean` themselves** (`ButtonSchema`, `SelectSchema`, +`SwitchSchema`, `ToggleGroupSchema` and nine more), so those pages are right. +`ButtonGroupSchema` is the one that does not, so it inherits `BaseSchema`'s +`boolean | string` and this page was the outlier rather than the convention. +Whether `ButtonGroupSchema` *should* narrow it like its thirteen siblings is a +types question, not a docs one, and is deliberately not answered here — the +renderer reads neither spelling today. + `onClick` is documented as declared and annotated as a runtime slot: it is `z.function()`, and objectui#4453 narrowed the runtime to `typeof === 'function'`, so a JSON author cannot supply one. diff --git a/content/docs/components/basic/button-group.mdx b/content/docs/components/basic/button-group.mdx index fb5bbffeb6..71de2f2eb4 100644 --- a/content/docs/components/basic/button-group.mdx +++ b/content/docs/components/basic/button-group.mdx @@ -39,7 +39,7 @@ interface ButtonGroupSchema { size?: 'default' | 'sm' | 'lg' | 'icon'; // States - disabled?: boolean; + disabled?: boolean | string; // boolean, or a predicate expression // Styling className?: string; diff --git a/packages/types/src/__tests__/button-group-doc-surface-6347.test.ts b/packages/types/src/__tests__/button-group-doc-surface-6347.test.ts index eec6142698..078e08a663 100644 --- a/packages/types/src/__tests__/button-group-doc-surface-6347.test.ts +++ b/packages/types/src/__tests__/button-group-doc-surface-6347.test.ts @@ -50,15 +50,23 @@ * shared `enumOptions` reader rather than a hand-copied string list, so this * file follows the platform instead of asserting yesterday's vocabulary. * - * ## Two BaseSchema-inherited rows are pinned for MEMBERSHIP only — deliberate + * ## The two BaseSchema-inherited rows are pinned too, and one of them was wrong * * The page also documents `disabled` and `className`, which `ButtonGroupSchema` - * inherits. Their type TEXT is not pinned here: `BaseSchema` declares - * `disabled?: boolean | string` (the string limb is the expression dialect), - * while this page — and all 21 sibling `content/docs/components/**` pages that - * document it, and the common-props table in `content/docs/api/schema-reference.md` - * — spell it `boolean`. Correcting one page would make it the lone outlier of a - * repo-wide convention, so it is measured and reported rather than guessed at. + * inherits rather than declares. `disabled` was spelled `boolean` against a + * declared `boolean | string` (`base.ts`; the mirror is + * `z.union([z.boolean(), z.string()])`, the string limb being the predicate + * dialect `disabledOn` also carries). + * + * That reads at first like a repo-wide convention worth leaving alone — 14 + * `content/docs/components/**` pages spell a component schema's own `disabled` + * as `boolean`. It is not: 13 of those 14 component schemas REDECLARE + * `disabled?: boolean` themselves (`ButtonSchema`, `SelectSchema`, + * `SwitchSchema`, `ToggleGroupSchema` and nine more), so their pages are right. + * `ButtonGroupSchema` is the ONE that does not redeclare it, which makes this + * page the outlier rather than the convention. Measured, not assumed — and it + * is why the type TEXT of both inherited rows is asserted here rather than + * waved through as "documented centrally". * * ## `## Selection Mode` is gone because the renderer cannot draw it * @@ -149,6 +157,36 @@ const BASE_KEYS = new Set(Object.keys(BaseSchema.shape)); const declaredOptional = (shape: ZodShape, key: string): boolean => shape[key].safeParse(undefined).success; +/** + * Peel `.optional()` / `.default()` / `.nullable()` off a mirror member. + * + * Bounded rather than `while`, for the reason `@object-ui/test-support`'s + * `enumOptions` gives: the step is reached through `unknown`, so a node that + * unwraps to itself ends the walk instead of the process. That shared reader is + * deliberately NOT used here — it answers "which enum NAMES does this accept", + * and reading a union's option SCHEMAS out of it would be relying on a return + * value its contract does not promise. + */ +function unwrapWrappers(node: unknown): Record | undefined { + let carrier = node as Record | undefined; + for (let depth = 0; carrier && depth <= 8; depth += 1) { + const inner = carrier.def?.innerType ?? carrier._def?.innerType; + if (!inner) return carrier; + carrier = inner as Record; + } + return carrier; +} + +/** The declared type of a member, spelled the way the page writes it. */ +function declaredTypeText(node: unknown): string { + const inner = unwrapWrappers(node); + if (inner?.def?.type === 'union') { + const options = (inner.options ?? inner.def.options ?? []) as unknown[]; + return options.map((option) => unwrapWrappers(option)?.def?.type ?? 'unknown').join(' | '); + } + return String(inner?.def?.type ?? 'unknown'); +} + /** * The members this component declares in its OWN right. * @@ -214,12 +252,22 @@ describe('button-group.mdx: the `ButtonGroupSchema` block IS the shipped mirror }); it.each(['disabled', 'className'])( - 'the inherited `%s` row it documents is a declared member (membership only — see header)', + 'the inherited `%s` row it documents matches the declared member', (key) => { expect(BASE_KEYS.has(key)).toBe(true); expect(groupDoc.get(key)?.optional).toBe(declaredOptional(groupShape, key)); + expect(groupDoc.get(key)?.typeText).toBe(declaredTypeText(groupShape[key])); }, ); + + it('`ButtonGroupSchema` is the one component schema that does NOT narrow `disabled`', () => { + // The reason the row above says `boolean | string` while 13 sibling pages + // correctly say `boolean`: those 13 schemas redeclare it. This assertion is + // what stops a later `disabled?: boolean` narrowing on ButtonGroupSchema + // from leaving the page silently over-stating instead of under-stating. + expect(declaredTypeText(groupShape.disabled)).toBe('boolean | string'); + expect(Object.keys(groupShape).includes('disabled')).toBe(true); + }); }); describe('button-group.mdx: no Selection Mode section, because the renderer has no selection (objectui#6347)', () => { @@ -275,4 +323,14 @@ describe('counter-probes: the readers above can still fail (objectui#6347)', () enumOptions(groupShape.variant), ); }); + + it('`declaredTypeText` reads the mirror, not a hard-coded string', () => { + // Non-vacuity for the union reader: it must distinguish the two inherited + // rows from each other, and must not answer `unknown` for either. + expect(declaredTypeText(groupShape.className)).toBe('string'); + expect(declaredTypeText(groupShape.disabled)).not.toBe( + declaredTypeText(groupShape.className), + ); + expect(declaredTypeText(groupShape.disabled)).not.toContain('unknown'); + }); }); From 0f49c91c7e23e1391ff24ef457f594284b51884a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 16:44:53 +0000 Subject: [PATCH 4/4] test(types): type the wrapper carrier instead of `any` in the doc-surface pin `@typescript-eslint/no-explicit-any` warned three times on the union reader. Same shape as `@object-ui/test-support`'s `EnumCarrier`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .../button-group-doc-surface-6347.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/types/src/__tests__/button-group-doc-surface-6347.test.ts b/packages/types/src/__tests__/button-group-doc-surface-6347.test.ts index 078e08a663..2b58359f7a 100644 --- a/packages/types/src/__tests__/button-group-doc-surface-6347.test.ts +++ b/packages/types/src/__tests__/button-group-doc-surface-6347.test.ts @@ -167,12 +167,22 @@ const declaredOptional = (shape: ZodShape, key: string): boolean => * and reading a union's option SCHEMAS out of it would be relying on a return * value its contract does not promise. */ -function unwrapWrappers(node: unknown): Record | undefined { - let carrier = node as Record | undefined; +interface WrapperCarrier { + readonly options?: readonly unknown[]; + readonly def?: { + readonly type?: string; + readonly innerType?: unknown; + readonly options?: readonly unknown[]; + }; + readonly _def?: { readonly innerType?: unknown }; +} + +function unwrapWrappers(node: unknown): WrapperCarrier | undefined { + let carrier = node as WrapperCarrier | undefined; for (let depth = 0; carrier && depth <= 8; depth += 1) { const inner = carrier.def?.innerType ?? carrier._def?.innerType; if (!inner) return carrier; - carrier = inner as Record; + carrier = inner as WrapperCarrier; } return carrier; } @@ -181,7 +191,7 @@ function unwrapWrappers(node: unknown): Record | undefined { function declaredTypeText(node: unknown): string { const inner = unwrapWrappers(node); if (inner?.def?.type === 'union') { - const options = (inner.options ?? inner.def.options ?? []) as unknown[]; + const options = inner.options ?? inner.def.options ?? []; return options.map((option) => unwrapWrappers(option)?.def?.type ?? 'unknown').join(' | '); } return String(inner?.def?.type ?? 'unknown');