Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/6938-checkbox-wrapper-class.md
Original file line number Diff line number Diff line change
@@ -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.
160 changes: 160 additions & 0 deletions packages/types/src/__tests__/checkbox-wrapper-class-6938.test.ts
Original file line number Diff line number Diff line change
@@ -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<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
/** The canonical `any` detector: only `any` absorbs `1 &` down to something `0` extends. */
type IsAny<T> = 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<any, string>` is false.
export type _WrapperClassIsString = Expect<Equal<NonNullable<TsCheckboxSchema['wrapperClass']>, string>>;
export type _WrapperClassIsNotAny = Expect<Equal<IsAny<TsCheckboxSchema['wrapperClass']>, 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<IsAny<TsCheckboxSchema['labelClass']>>;

// 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<string> {
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<string, unknown> }).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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[CONTROL_KEY]).toBe(42);
});

it('the type-level bindings above are referenced, so lint keeps them', () => {
expect(literal.wrapperClass).toBe('gap-4');
});
});
11 changes: 11 additions & 0 deletions packages/types/src/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
2 changes: 2 additions & 0 deletions packages/types/src/zod/form.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Loading