diff --git a/.changeset/6938-checkbox-wrapper-class.md b/.changeset/6938-checkbox-wrapper-class.md new file mode 100644 index 000000000..edd69c7d0 --- /dev/null +++ b/.changeset/6938-checkbox-wrapper-class.md @@ -0,0 +1,21 @@ +--- +'@object-ui/types': patch +--- + +Declare `wrapperClass` on `CheckboxSchema`, on both faces (objectui#6938 — the +residue of that card; its `context-menu` half landed with objectui#6939 group 1). + +`packages/components/src/renderers/form/checkbox.tsx:36` reads +`cn("flex items-center space-x-2", schema.wrapperClass)` — classes on the wrapper +`div` around the box and its label — and neither the TypeScript interface in +`packages/types/src/form.ts` nor the zod mirror in `zod/form.zod.ts` declared the +key. It compiled through `BaseSchema`'s index signature and parsed through +`.passthrough()`, admitted unexamined. The same key, on the same class of read, is +declared on `FileUploadSchema` and `FilterBuilderSchema` (objectui#6150); the +checkbox was left out only because its doc page's schema block is a six-line +summary. + +**patch, not minor: the accept set only widens toward what already renders.** The +key is optional; no document that validated before stops validating. In the value +dimension the mirror now REFUSES a non-string `wrapperClass` it used to admit +unexamined — enforcement of the declared type, not a new capability. diff --git a/packages/types/src/__tests__/checkbox-wrapper-class-6938.test.ts b/packages/types/src/__tests__/checkbox-wrapper-class-6938.test.ts new file mode 100644 index 000000000..dba76dee1 --- /dev/null +++ b/packages/types/src/__tests__/checkbox-wrapper-class-6938.test.ts @@ -0,0 +1,160 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * objectui#6938 — `CheckboxSchema.wrapperClass`, the residue of that card. + * + * ## The defect + * + * `packages/components/src/renderers/form/checkbox.tsx:36` reads + * `cn("flex items-center space-x-2", schema.wrapperClass)` — classes on the + * wrapper `div` around the box and its label — and neither face of the shipped + * contract declared the key: not the TypeScript interface in `../form.ts`, not + * the zod mirror in `../zod/form.zod.ts`. The read compiled through + * `BaseSchema`'s index signature (objectui#5155) and the value parsed through + * `.passthrough()`, admitted unexamined. The same key, on the same class of + * read, IS declared on `FileUploadSchema` and `FilterBuilderSchema` + * (objectui#6150); the checkbox was left out only because its doc page's + * schema block is a six-line summary — the asymmetry is the docs', not the + * renderer's. + * + * The card's other half (`ContextMenuSchema`'s three read keys and its dead + * required `children`) landed with objectui#6939 group 1 and is not pinned + * here. + * + * ## What this file pins, and the shape it borrows + * + * The form is `undeclared-but-consumed-keys-6150.test.ts`, unchanged: + * membership is asserted on the mirror's OWN `.shape`, never on parse + * acceptance — under `.passthrough()` acceptance cannot tell "declared" from + * "admitted unexamined". The type-level pin uses invariant equality, so an + * undeclared key (which resolves to `any` through the index signature) reads + * as a failure rather than as a match. + * + * The CONTROL is a key the renderer does NOT read — derived from the renderer + * source, not asserted from memory — and it must stay undeclared on both + * faces. That is the half that keeps this from being a widening: the change + * declares the one key the renderer honours and nothing else. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { CheckboxSchema } from '../zod/form.zod'; +import { safeValidateSchema } from '../zod/index.zod'; +import type { CheckboxSchema as TsCheckboxSchema } from '../form'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); +const READER = 'packages/components/src/renderers/form/checkbox.tsx'; +const READ_TEXT = 'cn("flex items-center space-x-2", schema.wrapperClass)'; + +const KEY = 'wrapperClass'; +/** + * A plausible-looking class key the renderer never reads: the label's classes + * are hard-coded at `checkbox.tsx:52`. It stays undeclared on both faces. + */ +const CONTROL_KEY = 'labelClass'; +/** A declared-keys-only document; every assertion below is a delta on it. */ +const CONTROL = { type: 'checkbox', label: 'Accept' }; + +/* ── Type-level pins (invariant equality, house form) ─────────────────────── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; +/** The canonical `any` detector: only `any` absorbs `1 &` down to something `0` extends. */ +type IsAny = 0 extends (1 & T) ? true : false; + +// Declared as `string`. Were the member removed, the read would fall back to +// the index signature and resolve to `any`, and `Equal` is false. +export type _WrapperClassIsString = Expect, string>>; +export type _WrapperClassIsNotAny = Expect, false>>; +// The control key is NOT declared: it resolves to `any` through the index +// signature, exactly as `wrapperClass` did before this card. Declaring it turns +// this red — which is the point. +export type _ControlKeyFallsThroughToIndexSignature = Expect>; + +// The TS face accepts the key on a literal. ⚠️ This is the WEAK half: the index +// signature would accept it undeclared too. The invariant pins above are the +// guard; this line only shows the declared spelling in use. +const literal: TsCheckboxSchema = { type: 'checkbox', label: 'Accept', wrapperClass: 'gap-4' }; + +/** Every `schema.KEY` read in the renderer, off disk. */ +function rendererReads(): Set { + const src = readFileSync(join(REPO_ROOT, READER), 'utf8'); + return new Set([...src.matchAll(/\bschema\.([A-Za-z_$][\w$]*)/g)].map((m) => m[1])); +} + +function shapeKeys(): string[] { + return Object.keys((CheckboxSchema as unknown as { shape: Record }).shape); +} + +describe('objectui#6938 — the renderer reads `wrapperClass`, which is the fact the declaration records', () => { + it('the read is still there, as the exact text the docblocks cite', () => { + const src = readFileSync(join(REPO_ROOT, READER), 'utf8'); + expect(src, `${READER} no longer reads \`schema.${KEY}\` as \`${READ_TEXT}\``).toContain(READ_TEXT); + }); + + it('the read set, derived from the renderer, contains the key and NOT the control key', () => { + // Non-vacuity for the control: if the renderer ever starts reading + // `labelClass`, this turns red and the control must be re-chosen, not + // declared on the way past. + const reads = rendererReads(); + expect(reads.has(KEY)).toBe(true); + expect(reads.has(CONTROL_KEY)).toBe(false); + }); +}); + +describe('objectui#6938 — the zod mirror declares it', () => { + it('is a member of the mirror shape (membership cannot be read off acceptance under passthrough)', () => { + expect(shapeKeys()).toContain(KEY); + }); + + it('accepts the declared value and the value SURVIVES the parse', () => { + const r = CheckboxSchema.safeParse({ ...CONTROL, [KEY]: 'gap-4' }); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + if (r.success) expect((r.data as Record)[KEY]).toBe('gap-4'); + }); + + it('…and through the published union entry point, so the `checkbox` arm is the one reached', () => { + const r = safeValidateSchema({ ...CONTROL, [KEY]: 'gap-4' }); + expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true); + if (r.success) expect((r.data as Record)[KEY]).toBe('gap-4'); + }); + + it('refuses a wrong-typed value AT the key — the enforcement mirroring adds', () => { + // Before this card `wrapperClass: 42` parsed green under `.passthrough()`. + // This is the only verdict that moves, and it moves toward refusal. + const r = CheckboxSchema.safeParse({ ...CONTROL, [KEY]: 42 }); + 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(CheckboxSchema.safeParse(CONTROL).success).toBe(true); + }); +}); + +describe('objectui#6938 — the control key stays undeclared, so nothing outside the one key moved', () => { + it('is ABSENT from the mirror shape', () => { + expect(shapeKeys()).not.toContain(CONTROL_KEY); + }); + + it('the SAME wrong-typed value under the control key is still admitted unexamined', () => { + // The before-state of `wrapperClass`, kept on purpose on a key that is not + // read: `.passthrough()` admits it, of any type, and it survives. This is + // the proof the mirror's unknown-key policy is byte-for-byte what it was. + const r = CheckboxSchema.safeParse({ ...CONTROL, [CONTROL_KEY]: 42 }); + expect(r.success).toBe(true); + if (r.success) expect((r.data as Record)[CONTROL_KEY]).toBe(42); + }); + + it('the type-level bindings above are referenced, so lint keeps them', () => { + expect(literal.wrapperClass).toBe('gap-4'); + }); +}); diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index 1dc1ec4d1..12dd197f0 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -341,6 +341,17 @@ export interface CheckboxSchema extends BaseSchema { * Declared by objectui#6150; one of the two behavioural keys in that census. */ required?: boolean; + /** + * Classes on the wrapper `div` around the box and its label. + * + * READ SITE: `packages/components/src/renderers/form/checkbox.tsx:36` — + * `cn("flex items-center space-x-2", schema.wrapperClass)`. Undeclared until + * objectui#6938, surviving only on `BaseSchema`'s index signature: the same + * key, on the same class of read, that `FileUploadSchema` and + * `FilterBuilderSchema` declare (objectui#6150) — left out here only because + * the checkbox doc page's schema block is a six-line summary. + */ + wrapperClass?: string; /** * Change handler * diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index 23a63abe3..21ee14542 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -260,6 +260,8 @@ export const CheckboxSchema = BaseSchema.extend({ checked: z.boolean().optional().describe('Controlled checked state'), 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)"), + wrapperClass: z.string().optional() + .describe('Classes on the wrapper div around the box and its label, read at renderers/form/checkbox.tsx:36 — `cn("flex items-center space-x-2", schema.wrapperClass)` (objectui#6938)'), description: z.string().optional().describe('Help text'), error: z.string().optional().describe('Error message'), onChange: handlerKeyRefusal('onChange', 'runtime-slot', 'Change handler'),