diff --git a/.changeset/6150-undeclared-but-consumed-keys.md b/.changeset/6150-undeclared-but-consumed-keys.md new file mode 100644 index 0000000000..3f5b0bf80a --- /dev/null +++ b/.changeset/6150-undeclared-but-consumed-keys.md @@ -0,0 +1,68 @@ +--- +'@object-ui/types': minor +--- + +Declare the 13 renderer-read keys that no shipped type declared (objectui#6150) + +**This is a published-surface change on `@object-ui/types` and its `zod` mirrors, +and it moves the accept set in TWO directions.** Read the next two paragraphs +before reading the list — they are what the change actually is. + +**Key membership is NOT widened — it was never narrow.** All eight touched mirrors +extend `BaseSchema`, which is `.passthrough()`, and `.extend()` carries that policy +through (measured on the built mirrors: `catchall` is `z.unknown()` on all eight). +So before this change every one of the 13 keys already parsed green and already +SURVIVED the parse — admitted unexamined, neither refused nor stripped. Nothing +that parsed before stops parsing because a key became known. + +**Value enforcement IS widened, which in the value dimension is a NARROWING.** For +the 12 keys that gained a zod mirror entry, the value is now validated against the +declared type: `{ type: 'text', content: 42 }` parsed green before and is refused +now, at `content`. That is the point of declaring them — `declared === enforced` — +but it is a behaviour change for documents that carried a wrong-typed value under +one of these 13 names. Keys OUTSIDE the 13 are untouched: an undeclared key of any +type is still admitted unexamined on all eight mirrors, pinned per mirror. + +The 13, each with the renderer read site the declaration records: + +| type | key | declared as | read at | +|---|---|---|---| +| `TextSchema` | `content` | `string` | `renderers/basic/text.tsx` — `{schema.content \|\| schema.value}` | +| `CarouselSchema` | `opts` | `Record` | `complex/carousel.tsx` — `opts={schema.opts}` | +| `CarouselSchema` | `orientation` | `'horizontal' \| 'vertical'` | `complex/carousel.tsx` | +| `CarouselSchema` | `itemClassName` | `string` | `complex/carousel.tsx` — per-slide class | +| `FilterBuilderSchema` | `wrapperClass` | `string` | `complex/filter-builder.tsx` | +| `TreeViewSchema` | `nodes` | `TreeNode[]` | `data-display/tree-view.tsx` | +| `TreeViewSchema` | `title` | `string` | `data-display/tree-view.tsx` | +| `TreeViewSchema` | `onNodeClick` | `(node: TreeNode) => void` | `data-display/tree-view.tsx` — INVOKED | +| `CheckboxSchema` | `required` | `boolean` | `form/checkbox.tsx` — drives the `*` marker | +| `FileUploadSchema` | `buttonText` | `string` | `form/file-upload.tsx` | +| `FileUploadSchema` | `wrapperClass` | `string` | `form/file-upload.tsx` | +| `HoverCardSchema` | `align` | `OverlayAlignment` | `overlay/hover-card.tsx` | +| `ContextMenuSchema` | `trigger` | `SchemaNode \| SchemaNode[]` | `overlay/context-menu.tsx` | + +These compiled before only because `BaseSchema` ends with `[key: string]: any` +(objectui#5155), so the docs page was the single place in the repo recording each +capability, and the one place with no mechanical guard. + +Three declarations are deliberately not what "declare what is read" would produce +on its own, and each says so in its own doc comment: + +- `CarouselSchema.opts` stays an OPEN bag rather than the docs page's + `{ loop?, align? }` pair. The renderer forwards the whole bag to embla, so + narrowing it to two keys would refuse authored documents that work today. +- `ContextMenuSchema.trigger` is OPTIONAL although the docs page shows it + required; the renderer substitutes a placeholder, so trigger-less documents are + legal today. +- `TreeViewSchema.onNodeClick` gets NO zod mirror. It is invoked, not read as a + value, so it cannot appear in an authored JSON document; objectui#6152 ruled + that class is recorded in `zod-mirror-parity.test.ts`'s `RuntimeOnlyDeclared` + instead, and it is (the first pair to sit there without also sitting in + `UnmirroredDeclared`, so that file's two counts move with it). + +Two of the 13 declare a SECOND spelling for a slot that already had one — +`TextSchema.content` beside `value`, `TreeViewSchema.nodes` beside `data` — because +that is what the renderers read. Retiring either spelling is an ADR-0049 +enforce-or-remove question and is deliberately not decided here. Declaring `nodes` +also does not by itself make a `nodes`-only tree-view document legal: `data` stays +required on both faces. diff --git a/packages/types/src/__tests__/undeclared-but-consumed-keys-6150.test.ts b/packages/types/src/__tests__/undeclared-but-consumed-keys-6150.test.ts new file mode 100644 index 0000000000..0fff7f0fba --- /dev/null +++ b/packages/types/src/__tests__/undeclared-but-consumed-keys-6150.test.ts @@ -0,0 +1,290 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The 13 renderer-read keys that no shipped type declared (objectui#6150). + * + * ## What the card measured, and what this file pins + * + * A census over all 76 `content/docs/components/**` pages found 68 documented + * keys that are not declared members of their shipped type; **13** were shown + * to be genuinely READ by the renderer. Those are capabilities that work at + * runtime, that the docs describe, and that the published contract never + * mentioned — the reads compile only because `BaseSchema` ends with + * `[key: string]: any` (objectui#5155), so `schema.trigger` on a type that + * never declared `trigger` resolves to `any` instead of erroring. + * + * Re-derived on this branch before anything was edited, through the SAME + * resolution path the census used (built `packages/types/dist/index.d.ts`, and + * cross-checked against `src/index.ts` — the two agreed on all 8 types): all 13 + * were still undeclared, and all 13 reads were still present. + * + * ## ⚠️ This is NOT a key-membership widening — measured, not assumed + * + * Every one of the 8 mirrors extends `BaseSchema`, which is `.passthrough()`, + * and `.extend()` carries that policy through (measured on the built mirrors: + * `catchall` is `z.unknown()` on all 8). So **before this card every one of the + * 13 keys already parsed green and already survived the parse** — admitted + * unexamined, not refused and not stripped. What changes here is: + * + * 1. **declaration** — the key becomes a member of the shipped TypeScript + * type, so an editor completes it and an annotation checks it; and + * 2. **enforcement** — for the 12 mirrored keys the value is now VALIDATED. + * In the value dimension that is a NARROWING, not a widening: `content: 42` + * parsed green before and is refused now. + * + * The same reading is why membership below is asserted on the mirror's own + * `.shape` and never on parse acceptance — under `.passthrough()`, acceptance + * cannot tell "declared" from "admitted unexamined" (the form + * `object-grid-title-mirrored.test.ts` established for objectui#6639). + * `undeclaredSentinel` is the control that keeps that distinction visible: an + * undeclared key of the wrong type is still admitted, exactly as before, on + * every one of the 8 mirrors. + * + * ## One of the 13 is not mirrored, deliberately + * + * `TreeViewSchema.onNodeClick` is INVOKED (`schema.onNodeClick(node)`), not read + * as a value. A function cannot appear in an authored JSON document, so it is a + * runtime slot; objectui#6152 ruled that class never gets a zod mirror and is + * recorded in `zod-mirror-parity.test.ts`'s `RuntimeOnlyDeclared` instead. Its + * assertions below are the mirror-image of the other twelve: declared on the TS + * face, ABSENT from the mirror shape, and pinned as a call signature. + * + * ## The read sites are pinned, not just described + * + * A declaration is worth its doc comment only while the read exists — and the + * card's own rule is that a key whose reader was removed must be DROPPED, never + * declared. So each entry carries the renderer file and the exact source text of + * its read, checked off disk (the form `base-bind-declared.test.ts` uses). Line + * numbers drift and are therefore in prose only; the READ is the fact. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { TextSchema } from '../zod/layout.zod'; +import { CarouselSchema, FilterBuilderSchema } from '../zod/complex.zod'; +import { TreeViewSchema } from '../zod/data-display.zod'; +import { CheckboxSchema, FileUploadSchema } from '../zod/form.zod'; +import { ContextMenuSchema, HoverCardSchema } from '../zod/overlay.zod'; + +import type { TextSchema as TsTextSchema } from '../layout'; +import type { CarouselSchema as TsCarouselSchema, FilterBuilderSchema as TsFilterBuilderSchema } from '../complex'; +import type { TreeNode, TreeViewSchema as TsTreeViewSchema } from '../data-display'; +import type { CheckboxSchema as TsCheckboxSchema, FileUploadSchema as TsFileUploadSchema } from '../form'; +import type { ContextMenuSchema as TsContextMenuSchema, HoverCardSchema as TsHoverCardSchema, OverlayAlignment } from '../overlay'; +import type { SchemaNode } from '../base'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); + +/* ── Type-level helpers (invariant equality, house form) ─────────────────── */ + +type Equal< A, B > = + (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; +type Expect< T extends true > = T; + +/** + * The declared TYPE of each of the 13, pinned invariantly. + * + * `[key: string]: any` would make every one of these `any` if the member were + * NOT declared, and `Equal< any, string >` is false — so these fail the moment a + * declaration is removed and the key falls back to the index signature. That is + * what makes them a guard and not a restatement. + */ +export type _ContentIsString = Expect< Equal< NonNullable< TsTextSchema['content'] >, string > >; +export type _OptsIsOpenBag = Expect< Equal< NonNullable< TsCarouselSchema['opts'] >, Record< string, unknown > > >; +export type _OrientationIsAxis = Expect< Equal< NonNullable< TsCarouselSchema['orientation'] >, 'horizontal' | 'vertical' > >; +export type _ItemClassNameIsString = Expect< Equal< NonNullable< TsCarouselSchema['itemClassName'] >, string > >; +export type _FilterWrapperClassIsString = Expect< Equal< NonNullable< TsFilterBuilderSchema['wrapperClass'] >, string > >; +export type _NodesIsTreeNodes = Expect< Equal< NonNullable< TsTreeViewSchema['nodes'] >, TreeNode[] > >; +export type _TreeTitleIsString = Expect< Equal< NonNullable< TsTreeViewSchema['title'] >, string > >; +export type _RequiredIsBoolean = Expect< Equal< NonNullable< TsCheckboxSchema['required'] >, boolean > >; +export type _ButtonTextIsString = Expect< Equal< NonNullable< TsFileUploadSchema['buttonText'] >, string > >; +export type _UploadWrapperClassIsString = Expect< Equal< NonNullable< TsFileUploadSchema['wrapperClass'] >, string > >; +export type _AlignIsOverlayAlignment = Expect< Equal< NonNullable< TsHoverCardSchema['align'] >, OverlayAlignment > >; +// ⚠️ No `NonNullable` here, unlike every line above: `SchemaNode` ITSELF admits +// `null | undefined` (`base.ts`), so `NonNullable` would strip those limbs out of +// the union and compare a different type. Read raw, the optional `?` adds +// `undefined` to a union that already carries it, so the two sides are equal — +// and the guard still bites, because an undeclared `trigger` would resolve to +// `any` through `BaseSchema`'s index signature and `Equal< any, … >` is false. +export type _TriggerIsNodeOrNodes = Expect< Equal< TsContextMenuSchema['trigger'], SchemaNode | SchemaNode[] > >; + +/** + * INVOKED, not read — so the declared type is a call signature taking the + * clicked node, not a value shape. Getting this wrong widens the surface + * incorrectly, which is why it is pinned separately and invariantly. + */ +export type _OnNodeClickIsNodeHandler = + Expect< Equal< NonNullable< TsTreeViewSchema['onNodeClick'] >, (node: TreeNode) => void > >; + +/* ── The 13, as data ─────────────────────────────────────────────────────── */ + +interface Case { + /** Mirror + TS type name, for the test title. */ + type: string; + key: string; + /** The zod mirror, or `null` for the one runtime-only key. */ + mirror: { + shape: Record< string, unknown >; + safeParse: (v: unknown) => { + success: boolean; + data?: Record< string, unknown >; + error?: { issues: { path: (string | number)[] }[] }; + }; + } | null; + /** A minimal LEGAL document for this type, carrying none of the 13. */ + control: Record< string, unknown >; + /** A value the declaration admits. */ + legal: unknown; + /** A value the declaration refuses — the enforcement this card adds. */ + illegal: unknown; + /** Renderer file, relative to the repo root. */ + reader: string; + /** Exact source text of the read, as it stands today. */ + readText: string; +} + +const NODE: SchemaNode = { type: 'text', value: 'x' } as SchemaNode; +const TEXT_CONTROL = { type: 'text' }; +const CAROUSEL_CONTROL = { type: 'carousel', items: [] }; +const FILTER_CONTROL = { type: 'filter-builder', fields: [] }; +const TREE_CONTROL = { type: 'tree-view', data: [] }; +const CHECKBOX_CONTROL = { type: 'checkbox', label: 'Accept' }; +const UPLOAD_CONTROL = { type: 'file-upload', label: 'Attach' }; +const HOVER_CONTROL = { type: 'hover-card', content: NODE, trigger: NODE }; +const MENU_CONTROL = { type: 'context-menu', items: [], children: NODE }; + +const R = 'packages/components/src/renderers/'; + +const CASES: Case[] = [ + { type: 'TextSchema', key: 'content', mirror: TextSchema as never, control: TEXT_CONTROL, + legal: 'hello', illegal: 42, + reader: R + 'basic/text.tsx', readText: '{schema.content || schema.value}' }, + + { type: 'CarouselSchema', key: 'opts', mirror: CarouselSchema as never, control: CAROUSEL_CONTROL, + legal: { loop: true, align: 'start' }, illegal: 'not-an-option-bag', + reader: R + 'complex/carousel.tsx', readText: 'opts={schema.opts}' }, + { type: 'CarouselSchema', key: 'orientation', mirror: CarouselSchema as never, control: CAROUSEL_CONTROL, + legal: 'vertical', illegal: 'diagonal', + reader: R + 'complex/carousel.tsx', readText: "orientation={schema.orientation || 'horizontal'}" }, + { type: 'CarouselSchema', key: 'itemClassName', mirror: CarouselSchema as never, control: CAROUSEL_CONTROL, + legal: 'basis-1/2', illegal: 42, + reader: R + 'complex/carousel.tsx', readText: 'className={schema.itemClassName}' }, + + { type: 'FilterBuilderSchema', key: 'wrapperClass', mirror: FilterBuilderSchema as never, control: FILTER_CONTROL, + legal: 'p-4', illegal: 42, + reader: R + 'complex/filter-builder.tsx', readText: "className={schema.wrapperClass || ''}" }, + + { type: 'TreeViewSchema', key: 'nodes', mirror: TreeViewSchema as never, control: TREE_CONTROL, + legal: [{ id: 'a', label: 'A' }], illegal: 'not-an-array', + reader: R + 'data-display/tree-view.tsx', readText: 'boundData || schema.nodes || schema.data || []' }, + { type: 'TreeViewSchema', key: 'title', mirror: TreeViewSchema as never, control: TREE_CONTROL, + legal: 'Folders', illegal: 42, + reader: R + 'data-display/tree-view.tsx', readText: '{schema.title}' }, + // INVOKED, not read as a value — no mirror, by objectui#6152's ruling. + { type: 'TreeViewSchema', key: 'onNodeClick', mirror: null, control: TREE_CONTROL, + legal: () => {}, illegal: undefined, + reader: R + 'data-display/tree-view.tsx', readText: 'schema.onNodeClick(node)' }, + + { type: 'CheckboxSchema', key: 'required', mirror: CheckboxSchema as never, control: CHECKBOX_CONTROL, + legal: true, illegal: 'yes', + reader: R + 'form/checkbox.tsx', readText: 'required={schema.required}' }, + + { type: 'FileUploadSchema', key: 'buttonText', mirror: FileUploadSchema as never, control: UPLOAD_CONTROL, + legal: 'Choose files', illegal: 42, + reader: R + 'form/file-upload.tsx', readText: 'schema.buttonText ||' }, + { type: 'FileUploadSchema', key: 'wrapperClass', mirror: FileUploadSchema as never, control: UPLOAD_CONTROL, + legal: 'mt-2', illegal: 42, + reader: R + 'form/file-upload.tsx', readText: "${schema.wrapperClass || ''}" }, + + { type: 'HoverCardSchema', key: 'align', mirror: HoverCardSchema as never, control: HOVER_CONTROL, + legal: 'start', illegal: 'middle', + reader: R + 'overlay/hover-card.tsx', readText: 'align={schema.align}' }, + + // ⚠️ The enforcement `trigger` gains is REAL but weak, and the weakness is the + // union's, not this card's: `SchemaNodeSchema` admits a node object, a string, + // a number, a boolean, `null` and `undefined`, so `trigger: 42` is a LEGAL + // node. The refused value below is an object with no `type` — refused by every + // limb. `HoverCardSchema.trigger`, already declared, has exactly this reach. + { type: 'ContextMenuSchema', key: 'trigger', mirror: ContextMenuSchema as never, control: MENU_CONTROL, + legal: NODE, illegal: { notANode: true }, + reader: R + 'overlay/context-menu.tsx', readText: 'renderChildren(schema.trigger ||' }, +]; + +/** An undeclared key, carried by the control so the before-state stays visible. */ +const SENTINEL = 'undeclaredControlKey6150'; + +describe('objectui#6150 — the 13 renderer-read keys are declared on their shipped types', () => { + it('the batch is exactly 13 keys over 8 shipped types', () => { + // Non-vacuity for every per-case assertion below, and the card's own bound: + // the census found 68 undeclared documented keys and only these 13 were + // shown to be genuinely read. A 14th belongs on its own card, not here. + expect(CASES).toHaveLength(13); + expect(new Set(CASES.map((c) => c.type)).size).toBe(8); + }); + + describe.each(CASES.map((c) => [`${c.type}.${c.key}`, c] as const))('%s', (_title, c) => { + const { key, mirror, control, legal, illegal, reader, readText } = c; + it('the renderer still reads it — the fact the declaration records', () => { + const src = readFileSync(join(REPO_ROOT, reader), 'utf8'); + expect(src, `${reader} no longer reads \`schema.${key}\` as \`${readText}\``).toContain(readText); + }); + + if (mirror) { + it('is a member of the mirror shape (membership cannot be read off acceptance under passthrough)', () => { + expect(Object.keys(mirror.shape)).toContain(key); + }); + + it('accepts the declared value and the value SURVIVES the parse', () => { + const r = mirror.safeParse({ ...control, [key]: legal }); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + if (r.success) expect(r.data![key]).toEqual(legal); + }); + + it('refuses a wrong-typed value AT the key — the enforcement mirroring adds', () => { + const r = mirror.safeParse({ ...control, [key]: illegal }); + expect(r.success).toBe(false); + if (!r.success) { + expect(r.error!.issues.map((i) => i.path.join('.'))).toContain(key); + } + }); + + it('control: the declared-keys-only document parses green, before and after', () => { + expect(mirror.safeParse(control).success).toBe(true); + }); + + it('control: the SAME wrong-typed value under an UNDECLARED key is still admitted unexamined', () => { + // The before-state of all 13, kept on purpose. `.passthrough()` admits + // an undeclared key of any type — which is what each of these keys did + // before this card, and is why the refusal above measures the new + // enforcement rather than the base object's strictness. It is also the + // proof that nothing OUTSIDE the 13 moved: the unknown-key policy of + // every touched mirror is byte-for-byte the policy it had before. + const r = mirror.safeParse({ ...control, [SENTINEL]: illegal }); + expect(r.success).toBe(true); + if (r.success) expect(r.data![SENTINEL]).toEqual(illegal); + }); + } else { + it('is DECLARED on the TS face but ABSENT from the mirror — a runtime slot, per objectui#6152', () => { + // The type-level pin `_OnNodeClickIsNodeHandler` above carries the + // declaration half; this is the deliberate asymmetry, recorded in + // `zod-mirror-parity.test.ts`'s `RuntimeOnlyDeclared`. + expect(Object.keys(TreeViewSchema.shape)).not.toContain(key); + }); + + it('a document carrying it is admitted unexamined, exactly as before', () => { + const r = TreeViewSchema.safeParse({ ...control, [key]: legal }); + expect(r.success).toBe(true); + }); + + it('is INVOKED at its read site, not merely read', () => { + const src = readFileSync(join(REPO_ROOT, reader), 'utf8'); + expect(src).toContain('if (schema.onNodeClick)'); + expect(src).toContain('schema.onNodeClick(node)'); + }); + } + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 1fe01a8cb0..04242ecd3b 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -65,9 +65,11 @@ * Anything citing "121" as the mirroring debt is citing a number that changed * meaning — the comparable figure is 97 + 1 mirrored + 23 reclassified. The * full statement is on that ledger. - * - **6 entries** in `RuntimeOnlyDeclared`, **23 keys** across them — a strict - * subset of the 16 pairs above, so the "no entry in either" population is - * unchanged. + * - **7 entries** in `RuntimeOnlyDeclared`, **24 keys** across them. Six of the + * seven are a subset of the 16 pairs above; `TreeViewSchema` is NOT — it is + * the first pair whose ONLY ledger entry is a runtime-only one + * (objectui#6150 declared `onNodeClick` on an otherwise clean pair), so the + * "no entry in either" population dropped by one to 141. * - 158 − 12 = **146**, the "pairs with no entry" `LedgerMismatch` speaks of. * * ## Two ratchets, because the forward comparison has two halves @@ -248,7 +250,7 @@ export type ReconcileAgainstLedger< K, Measured, Recorded > = export type assertionRatchetAcceptsAgreement = Expect< Equal< ReconcileAgainstLedger< 'p', 'a', 'a' >, never > >; -/** …and so does a clean pair with no entry, which is the case for 142 of the 158. */ +/** …and so does a clean pair with no entry, which is the case for 141 of the 158. */ export type assertionRatchetAcceptsCleanPair = Expect< Equal< ReconcileAgainstLedger< 'p', never, never >, never > >; @@ -1040,6 +1042,23 @@ interface RuntimeOnlyDeclared { * pair. POLICY group. */ 'objectql.zod.ts#ObjectViewSchema': 'onNavigate'; + /** + * `TreeViewSchema`'s ONLY entry in either ledger — the pair was clean before + * objectui#6150 and this key is the whole of its debt. + * + * OVERSIGHT group by mirror shape (`onSelectChange` and `onExpandChange` are + * mirrored beside it as `z.function()`), but it arrives here as a DECLARATION, + * not a discovery: objectui#6150's census measured `schema.onNodeClick` INVOKED + * at `renderers/data-display/tree-view.tsx:98,99` against a type that declared + * nothing, and the card declared it. A function cannot appear in an authored + * JSON document, so the key is a runtime slot and objectui#6152's ruling routes + * it here rather than to a mirror — the step-3 exception in the header above, + * used exactly as written. + * + * ⚠️ This is the first pair to sit in `RuntimeOnlyDeclared` without also sitting + * in `UnmirroredDeclared`; the two counts in the file header record that. + */ + 'data-display.zod.ts#TreeViewSchema': 'onNodeClick'; /** * 3 of `DetailViewSchema`'s former 14 — the exact three the 2026-07 audit named. By * mirror SHAPE this is the oversight group (`onBack` is mirrored, as `z.string()`), @@ -1169,10 +1188,12 @@ export const assertionDriftMatchesLedger: never = 0 as unknown as LedgerMismatch /** * The SECOND half of the forward comparison: every pair's declared-but-unmirrored - * key set equals what the two ledgers TOGETHER record for it — `never` for the 142 - * pairs with no entry in either (158 − 16; `RuntimeOnlyDeclared`'s 6 pairs are a - * measured subset of `UnmirroredDeclared`'s 16, so the clean population is unchanged - * by objectui#6152's reclassification). + * key set equals what the two ledgers TOGETHER record for it — `never` for the 141 + * pairs with no entry in either (158 − 17). Six of `RuntimeOnlyDeclared`'s seven + * pairs are a measured subset of `UnmirroredDeclared`'s 16, so objectui#6152's + * reclassification left the clean population unchanged; objectui#6150 then added + * `TreeViewSchema`, whose only entry is runtime-only, which is why the union is 17 + * pairs and not 16. * * ⚠️ **The discriminating signal is the PER-PAIR set, not this file's exit code.** * The exit code is a whole-file verdict, so it moves only while the rest of the diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index d55a5d4f77..8759bb4866 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -347,6 +347,16 @@ export interface FilterBuilderSchema extends BaseSchema { * @default 3 */ maxDepth?: number; + /** + * Tailwind classes on the outermost wrapper `div`. + * + * READ SITE: `packages/components/src/renderers/complex/filter-builder.tsx:37` + * — `className={schema.wrapperClass || ''}`. + * + * Distinct from {@link BaseSchema.className}, which this renderer applies + * further in. Declared by objectui#6150. + */ + wrapperClass?: string; } /** @@ -398,6 +408,39 @@ export interface CarouselSchema extends BaseSchema { * Carousel items */ items: CarouselItem[]; + /** + * Option bag forwarded VERBATIM to the underlying embla carousel. + * + * READ SITE: `packages/components/src/renderers/complex/carousel.tsx:23` — + * `opts={schema.opts}`, passed straight through to the `Carousel` + * primitive, whose own `opts` is embla's `EmblaOptionsType`. + * + * ⚠️ Deliberately declared OPEN rather than as the two-key shape the docs + * page shows (`{ loop?, align? }`). The renderer forwards the whole bag, so + * every other embla option authored today reaches the library and works; + * declaring the documented pair would REFUSE those authored documents — + * a narrowing of a published surface, which is a ruling and not a + * declaration. objectui#6150 records the capability as it is; picking a + * narrower shape is escalated with that card. + */ + opts?: Record; + /** + * Scroll axis. + * + * READ SITE: `renderers/complex/carousel.tsx:24` — + * `orientation={schema.orientation || 'horizontal'}`. + * + * @default 'horizontal' + */ + orientation?: 'horizontal' | 'vertical'; + /** + * Tailwind classes applied to EACH slide (not the container — that is + * {@link BaseSchema.className}). + * + * READ SITE: `renderers/complex/carousel.tsx:30` — + * `className={schema.itemClassName}` on every `CarouselItem`. + */ + itemClassName?: string; /** * Auto-play interval (ms) */ diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index b648c58bf0..ee0373a168 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -1111,9 +1111,35 @@ export interface TreeNode { export interface TreeViewSchema extends BaseSchema { type: 'tree-view'; /** - * Tree data + * Tree data — the fallback spelling, read only when + * {@link TreeViewSchema.nodes} is absent. + * + * READ SITE: `packages/components/src/renderers/data-display/tree-view.tsx:105` + * — `const rawNodes = boundData || schema.nodes || schema.data || []`. */ data: TreeNode[]; + /** + * Tree data — the spelling the renderer reads FIRST. + * + * READ SITE: `renderers/data-display/tree-view.tsx:105`, the middle limb of + * `boundData || schema.nodes || schema.data || []`, so `nodes` WINS over + * {@link TreeViewSchema.data} when both are authored (and a `bind`-resolved + * value wins over both). + * + * ⚠️ Declaring `nodes` does NOT by itself make `{ type: 'tree-view', nodes }` + * a legal document: {@link TreeViewSchema.data} stays REQUIRED on both faces, + * so the validator still demands `data`. Relaxing that is an accept-set + * change and a separate ruling — objectui#6150 declares the read, nothing + * more. + */ + nodes?: TreeNode[]; + /** + * Heading rendered above the tree. + * + * READ SITE: `renderers/data-display/tree-view.tsx:115` (presence gate) and + * `:117` (the `h3` body). + */ + title?: string; /** * Default expanded node IDs */ @@ -1148,6 +1174,21 @@ export interface TreeViewSchema extends BaseSchema { * Node expand handler */ onExpandChange?: (expandedIds: string[]) => void; + /** + * Node click handler — INVOKED, not merely read, so this is a call + * signature and not a value shape. + * + * READ SITE: `packages/components/src/renderers/data-display/tree-view.tsx:98` + * (presence gate `if (schema.onNodeClick)`) and `:99` (the call + * `schema.onNodeClick(node)`), where `node` is the clicked + * {@link TreeNode}. The handler's return value is discarded. + * + * ⚠️ NOT mirrored in `../zod/data-display.zod.ts`, deliberately: a function + * cannot appear in an authored JSON document, so it is a runtime slot. + * objectui#6152 ruled that class never gets a mirror; it is recorded in + * `__tests__/zod-mirror-parity.test.ts`'s `RuntimeOnlyDeclared` instead. + */ + onNodeClick?: (node: TreeNode) => void; } /** diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index 01ceb78962..79538ab151 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -345,6 +345,18 @@ export interface CheckboxSchema extends BaseSchema { * Error message */ error?: string; + /** + * Whether the box must be checked — drives a VISIBLE affordance, not just + * form semantics. + * + * READ SITES: `packages/components/src/renderers/form/checkbox.tsx:45` — + * `required={schema.required}` on the Radix `Checkbox` — and `:49`, where it + * gates the label's required marker + * (`schema.required && "text-destructive after:content-['*']"`). + * + * Declared by objectui#6150; one of the two behavioural keys in that census. + */ + required?: boolean; /** * Change handler */ @@ -592,6 +604,21 @@ export interface FileUploadSchema extends BaseSchema { * Error message */ error?: string; + /** + * Label on the drop zone / upload button. + * + * READ SITE: `packages/components/src/renderers/form/file-upload.tsx:123` — + * `{isUploading ? "…" : (schema.buttonText || "DROP PAYLOAD OR CLICK TO UPLOAD")}`. + */ + buttonText?: string; + /** + * Tailwind classes appended to the outer wrapper `div`. + * + * READ SITE: `renderers/form/file-upload.tsx:78` — appended to the + * renderer's own grid classes as + * `` `grid w-full … ${schema.wrapperClass || ''}` ``. + */ + wrapperClass?: string; /** * Change handler (receives FileList or File[]) */ diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts index 73e7740a19..a245ebd216 100644 --- a/packages/types/src/layout.ts +++ b/packages/types/src/layout.ts @@ -76,7 +76,30 @@ export interface TextSpanSchema extends BaseSchema { export interface TextSchema extends BaseSchema { type: 'text'; /** - * Text content to display + * Text content to display — the spelling the renderer reads FIRST. + * + * READ SITE: `packages/components/src/renderers/basic/text.tsx:51` (the + * wrapped `span` arm, taken when the node carries a designer id or a + * className) and `:56` (the bare fragment arm), both as + * `{schema.content || schema.value}`. `content` therefore WINS over + * {@link TextSchema.value} whenever both are authored. + * + * Declared by objectui#6150 (undeclared-but-consumed census). Before that + * card the renderer read this key through `BaseSchema`'s + * `[key: string]: any` (objectui#5155) and no shipped type mentioned it — + * the docs page was the only record of a capability that works. + * + * ⚠️ Two spellings for one slot is a dialect, not a design. Retiring one of + * them is an ADR-0049 enforce-or-remove question and is deliberately NOT + * decided here; this declaration records what the renderer does today. + */ + content?: string; + /** + * Text content — the fallback spelling, read only when + * {@link TextSchema.content} is absent or falsy. + * + * READ SITE: `renderers/basic/text.tsx:51,56`, the right-hand side of + * `{schema.content || schema.value}`. */ value?: string; /** diff --git a/packages/types/src/overlay.ts b/packages/types/src/overlay.ts index 9bc5738033..cbc78df087 100644 --- a/packages/types/src/overlay.ts +++ b/packages/types/src/overlay.ts @@ -318,6 +318,17 @@ export interface HoverCardSchema extends BaseSchema { * @default 300 */ closeDelay?: number; + /** + * Alignment of the card against its trigger. + * + * READ SITE: `packages/components/src/renderers/overlay/hover-card.tsx:24` — + * `align={schema.align}` on `HoverCardContent`, beside the already-declared + * `side={schema.side}`. + * + * Same vocabulary as {@link DropdownMenuSchema.align} and + * {@link PopoverSchema.align}; declared by objectui#6150. + */ + align?: OverlayAlignment; /** * Open state change handler */ @@ -458,6 +469,21 @@ export interface ContextMenuSchema extends BaseSchema { * Element to attach context menu to */ children: SchemaNode | SchemaNode[]; + /** + * The right-clickable area's content. + * + * READ SITE: `packages/components/src/renderers/overlay/context-menu.tsx:95` + * — `renderChildren(schema.trigger || { type: 'text', value: 'Right click here' })` + * inside `ContextMenuTrigger`. ⚠️ Note the renderer renders `trigger`, NOT + * the declared {@link ContextMenuSchema.children}, which no read site + * consumes. + * + * Declared OPTIONAL although the docs page shows it required: the renderer + * substitutes a placeholder when it is absent, so every document without a + * `trigger` is legal today and declaring it required would refuse them. + * Declared by objectui#6150. + */ + trigger?: SchemaNode | SchemaNode[]; } /** diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 5663070996..cfd30915ed 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -214,6 +214,8 @@ export const FilterBuilderSchema = BaseSchema.extend({ onChange: z.function().optional().describe('Change handler'), allowGroups: z.boolean().optional().describe('Allow grouped conditions'), maxDepth: z.number().optional().describe('Maximum nesting depth'), + wrapperClass: z.string().optional() + .describe("Outer wrapper classes, read at renderers/complex/filter-builder.tsx:37 — `className={schema.wrapperClass || ''}` (objectui#6150)"), }); /** @@ -230,6 +232,12 @@ export const CarouselItemSchema = z.object({ export const CarouselSchema = BaseSchema.extend({ type: z.literal('carousel'), items: z.array(CarouselItemSchema).describe('Carousel items'), + opts: z.record(z.string(), z.unknown()).optional() + .describe("Embla option bag forwarded verbatim at renderers/complex/carousel.tsx:23 — `opts={schema.opts}`. Left OPEN on purpose: the renderer passes the whole bag through, so narrowing it to the docs' `{loop, align}` pair would refuse authored documents that work today (objectui#6150)"), + orientation: z.enum(['horizontal', 'vertical']).optional() + .describe("Scroll axis, read at renderers/complex/carousel.tsx:24 — `orientation={schema.orientation || 'horizontal'}` (objectui#6150)"), + itemClassName: z.string().optional() + .describe('Per-slide Tailwind classes, read at renderers/complex/carousel.tsx:30 — `className={schema.itemClassName}` on every CarouselItem (objectui#6150)'), autoPlay: z.number().optional().describe('Auto-play interval (ms)'), showArrows: z.boolean().optional().describe('Show navigation arrows'), showDots: z.boolean().optional().describe('Show navigation dots'), diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index 74c7bfa60f..80c4651d1a 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -298,7 +298,12 @@ export const TreeNodeSchema: z.ZodType = z.lazy(() => */ export const TreeViewSchema = BaseSchema.extend({ type: z.literal('tree-view'), - data: z.array(TreeNodeSchema).describe('Tree data'), + data: z.array(TreeNodeSchema) + .describe('Tree data, read as the fallback limb of `boundData || schema.nodes || schema.data || []` at renderers/data-display/tree-view.tsx:105'), + nodes: z.array(TreeNodeSchema).optional() + .describe('Tree data, read FIRST at renderers/data-display/tree-view.tsx:105 — the middle limb of `boundData || schema.nodes || schema.data || []`, so it wins over `data`. ⚠️ `data` stays REQUIRED here: declaring `nodes` does not by itself make a `nodes`-only document legal (objectui#6150)'), + title: z.string().optional() + .describe('Heading above the tree, read at renderers/data-display/tree-view.tsx:115 (presence gate) and :117 (the h3 body) (objectui#6150)'), defaultExpandedIds: z.array(z.string()).optional().describe('Default expanded node IDs'), defaultSelectedIds: z.array(z.string()).optional().describe('Default selected node IDs'), expandedIds: z.array(z.string()).optional().describe('Controlled expanded node IDs'), diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index 1ba4d41409..6cde16a1d0 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -264,6 +264,8 @@ export const CheckboxSchema = BaseSchema.extend({ defaultChecked: z.boolean().optional().describe('Default checked state'), checked: z.boolean().optional().describe('Controlled checked state'), disabled: z.boolean().optional().describe('Whether checkbox is disabled'), + required: z.boolean().optional() + .describe("Required affordance, read at renderers/form/checkbox.tsx:45 (`required=` on the Radix Checkbox) and :49 (gates the label's `*` marker) (objectui#6150)"), description: z.string().optional().describe('Help text'), error: z.string().optional().describe('Error message'), onChange: z.function().optional().describe('Change handler'), @@ -339,6 +341,10 @@ export const FileUploadSchema = BaseSchema.extend({ type: z.literal('file-upload'), name: z.string().optional().describe('Field name for form submission'), label: z.string().optional().describe('Upload label'), + buttonText: z.string().optional() + .describe('Drop-zone label, read at renderers/form/file-upload.tsx:123 — `schema.buttonText || "DROP PAYLOAD OR CLICK TO UPLOAD"` (objectui#6150)'), + wrapperClass: z.string().optional() + .describe('Outer wrapper classes, appended to the renderer\'s own grid classes at renderers/form/file-upload.tsx:78 (objectui#6150)'), accept: z.string().optional().describe('Accepted file types'), multiple: z.boolean().optional().describe('Allow multiple files'), maxSize: z.number().optional().describe('Maximum file size (bytes)'), diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index 37bb33b853..a96fce18fe 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -59,7 +59,10 @@ export const TextSpanSchema = BaseSchema.extend({ */ export const TextSchema = BaseSchema.extend({ type: z.literal('text'), - value: z.string().optional().describe('Text content'), + content: z.string().optional() + .describe("Text content, read FIRST at renderers/basic/text.tsx:51,56 — `schema.content || schema.value`, so it wins over `value` (objectui#6150)"), + value: z.string().optional() + .describe('Text content, read as the fallback limb of `schema.content || schema.value` at renderers/basic/text.tsx:51,56'), variant: z.enum(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'body', 'caption', 'overline']) .optional() .default('body') diff --git a/packages/types/src/zod/overlay.zod.ts b/packages/types/src/zod/overlay.zod.ts index 16cc875656..8e2d74cd64 100644 --- a/packages/types/src/zod/overlay.zod.ts +++ b/packages/types/src/zod/overlay.zod.ts @@ -120,6 +120,8 @@ export const HoverCardSchema = BaseSchema.extend({ defaultOpen: z.boolean().optional().describe('Default open state'), open: z.boolean().optional().describe('Controlled open state'), side: z.enum(['top', 'right', 'bottom', 'left']).optional().describe('Hover card side'), + align: z.enum(['start', 'center', 'end']).optional() + .describe('Alignment against the trigger, read at renderers/overlay/hover-card.tsx:24 — `align={schema.align}` on HoverCardContent, beside the already-declared `side` (objectui#6150)'), openDelay: z.number().optional().describe('Delay before opening (ms)'), closeDelay: z.number().optional().describe('Delay before closing (ms)'), onOpenChange: z.function().optional().describe('Open change handler'), @@ -176,6 +178,8 @@ export const ContextMenuSchema = BaseSchema.extend({ type: z.literal('context-menu'), items: z.array(MenuItemSchema).describe('Menu items'), children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).describe('Context menu children'), + trigger: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional() + .describe("Right-clickable area, read at renderers/overlay/context-menu.tsx:95 — `renderChildren(schema.trigger || {type:'text', value:'Right click here'})`. Optional although the docs page shows it required: the renderer substitutes a placeholder, so trigger-less documents are legal today (objectui#6150)"), }); /**