diff --git a/.changeset/11441-known-types-retired-layout-keys.md b/.changeset/11441-known-types-retired-layout-keys.md index e71ef9a082..c0874375cd 100644 --- a/.changeset/11441-known-types-retired-layout-keys.md +++ b/.changeset/11441-known-types-retired-layout-keys.md @@ -16,3 +16,5 @@ The registered-types ratchet (`REFUSED_AT_TYPE`) falls from 20 to 18, and its na **Clause-②: yes**, released as `minor` with this banner. ⚠️ **Dated note, 2026-10-02 — `grid` takes column counts 1 to 12, not any number — objectui#11491.** At this change "the same breakpoint `columns` object" carried any count; now each count in a `grid`'s `columns` is one of 1 to 12, the counts the `grid` renderer maps, and `objectui validate` refuses any other with that set named. Every count `ResponsiveGrid` drew (1, 2, 3, 4, 6 and 12) is one of them, so the migration above holds for every `responsive-grid` that drew its columns. `.changeset/11491-grid-columns-set.md` states what ships. The rest of this entry is kept as the reading of this change. + +⚠️ **Dated note, 2026-10-02 — `grid` takes one of ten `gap` steps, not any number — objectui#11474.** At this change "the same breakpoint `columns` object and `gap`" carried any `gap` number; now `grid`'s `gap` is one of 0, 1, 2, 3, 4, 5, 6, 8, 10 and 12, the steps the `grid` renderer maps, and `objectui validate` refuses any other with that set named. Every step `ResponsiveGrid`'s own class map drew (0 to 6 and 8) is one of them, so the migration above holds for every `responsive-grid` whose `gap` drew a class. `.changeset/11474-layout-spacing-sets.md` states what ships. The rest of this entry is kept as the reading of this change. diff --git a/.changeset/11491-grid-columns-set.md b/.changeset/11491-grid-columns-set.md index 09905531b2..c68444ff54 100644 --- a/.changeset/11491-grid-columns-set.md +++ b/.changeset/11491-grid-columns-set.md @@ -40,3 +40,5 @@ nobody authored. Migration: replace the count with the one you meant, from 1 to 12. For a grid with no columns at a breakpoint, leave that breakpoint out; for a single column, write `1`. + +⚠️ **Dated note, 2026-10-02 — `grid` has no flat column inputs any more — objectui#11505.** At this change the `grid` registration published `smColumns`, `mdColumns`, `lgColumns` and `xlColumns` as closed enums of the twelve counts, and `validateTree` answered `smColumns: 13` with `invalid-enum`. Now the registration publishes `columns` as its only column input, the renderer no longer reads the four keys, both zod faces refuse them by name, and `validateTree` answers each with an `unknown-prop` warning. A per-breakpoint count is a member of the `columns` object, which this entry's closed set still governs. `.changeset/11505-grid-flat-columns-retired.md` states what ships. The rest of this entry is kept as the reading of this change. diff --git a/.changeset/11505-grid-flat-columns-retired.md b/.changeset/11505-grid-flat-columns-retired.md new file mode 100644 index 0000000000..cdd4d8d441 --- /dev/null +++ b/.changeset/11505-grid-flat-columns-retired.md @@ -0,0 +1,34 @@ +--- +'@object-ui/types': minor +'@object-ui/components': minor +--- + +A `grid` node sets a column count per breakpoint in one way: the breakpoint object of `columns`. +The flat `smColumns`, `mdColumns`, `lgColumns` and `xlColumns` keys are retired, with no alias +and no deprecation window (objectui#11505). + +**Breaking for a `grid` that carries a flat column key.** The `grid` registration offered the four +keys as inputs and seeded two of them, and the renderer read each one over the breakpoint object. +No face of `GridSchema` declared them: the strict authoring face refused each key at every value, +and `safeValidateSchema` (what `objectui validate` runs) passed any value through unexamined, so +`"mdColumns": "wide"` validated. + +- `@object-ui/types`: `GridSchema` declares the four keys as refusals on both faces. On the + TypeScript face each is a `?: never` tombstone, so `tsc` refuses it by name. The zod mirror + refuses every value at the key's own path with a message that names the breakpoint object member + to write instead (`columns: { md: N }`), on `safeValidateSchema` and on the strict authoring face + alike. The strict face answered `unrecognized_keys` before; it now gives that named refusal. +- `@object-ui/components`: the `grid` registration publishes `columns` as its only column input, + and seeds `columns: { xs: 1, md: 2, lg: 4 }` in place of `columns: 1`, `mdColumns: 2` and + `lgColumns: 4`. The seeded grid draws the same classes as before, + `grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4`. The renderer no longer reads the four + keys: a document that reaches it unvalidated draws exactly what it draws without them, and the + keys are never forwarded to the DOM. In the SDUI manifest, `validateTree` answers each with an + `unknown-prop` warning, where `smColumns: 13` drew `invalid-enum` before. + +Migration: move each count into the `columns` object under its breakpoint. A bare `columns: C` +beside a flat key becomes the object's `xs: C`, because a flat key switched off the bare count's +mobile-first ramp. With no `columns` at all, add `xs: 2` to keep the two columns the grid drew +below that breakpoint. So `{ "columns": 1, "mdColumns": 2, "lgColumns": 4 }` becomes +`{ "columns": { "xs": 1, "md": 2, "lg": 4 } }`, and `{ "smColumns": 3 }` becomes +`{ "columns": { "xs": 2, "sm": 3 } }`. Each migrated form draws the classes its flat form drew. diff --git a/.changeset/7097-grid-2xl-breakpoint.md b/.changeset/7097-grid-2xl-breakpoint.md index 58d4e5e5c6..d4ecbf83ed 100644 --- a/.changeset/7097-grid-2xl-breakpoint.md +++ b/.changeset/7097-grid-2xl-breakpoint.md @@ -31,3 +31,5 @@ compiled stylesheet, with the twelve `xl:grid-cols-*` rules unchanged as the con Nothing that rendered before renders differently: the other five tiers, the bare-number mobile-first ramp, and the designer's flat `smColumns`…`xlColumns` channel are unchanged. + +⚠️ **Dated note, 2026-10-02 — the flat column channel is retired — objectui#11505.** At this change the designer's flat `smColumns`…`xlColumns` channel was unchanged, as the last paragraph says. Now it is gone: the `grid` registration no longer offers the four keys, the renderer no longer reads them, and both zod faces refuse them by name. A per-breakpoint count, `2xl` included, is a member of the `columns` object. `.changeset/11505-grid-flat-columns-retired.md` states what ships. The rest of this entry is kept as the reading of this change. diff --git a/.changeset/8516-8556-mirror-partial-record-narrowing.md b/.changeset/8516-8556-mirror-partial-record-narrowing.md index f574ca52d0..8926cdac37 100644 --- a/.changeset/8516-8556-mirror-partial-record-narrowing.md +++ b/.changeset/8516-8556-mirror-partial-record-narrowing.md @@ -62,3 +62,5 @@ measurement is pinned executably in `__tests__/mirror-partial-record-narrowing-8516.test.ts`, at compile time (the inferred map is `Partial>`, not `Record<…>`) and at run time (one accepting row per member), so it cannot rot into folklore. + +⚠️ **Dated note, 2026-10-02 — the CLI reports `{ xxl: 6 }` as `unrecognized_keys` at `columns` — objectui#11073.** The Migration paragraph says the refusal reads `Path: columns → xxl`, `Code: invalid_key`, through `@object-ui/cli`'s union-arm expansion. Measured through `objectui validate`'s printer on objectui#11505's branch, it now reads `1. Unrecognized key: "xxl"`, `Path: columns`, `Code: unrecognized_keys`, as a top-level issue with no union-arm line under it. The verdict is unchanged: `{ xxl: 6 }` is refused, and the message still names `xxl`. The change of code is attributed to objectui#11073's zod 4.6 resolution (the rider on objectui#11505 names it); this note measured the present answer, not the transition. The rest of this entry is kept as the reading of this change. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 6d3701f414..4982c72cd0 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -246,7 +246,7 @@ A responsive grid layout. Columns can be a fixed number or responsive breakpoint | Property | Type | Description | |----------|------|-------------| -| `columns` | `ColumnCount \| Partial>` | A column count from 1 to 12 (`ColumnCount`), or a responsive map of such counts keyed by breakpoint (`xs`, `sm`, `md`, `lg`, `xl`, `2xl`), e.g. `{ sm: 1, md: 2, lg: 3 }`. Any other count is refused with the set named (objectui#11491). | +| `columns` | `ColumnCount \| Partial>` | A column count from 1 to 12 (`ColumnCount`), or a responsive map of such counts keyed by breakpoint (`xs`, `sm`, `md`, `lg`, `xl`, `2xl`), e.g. `{ sm: 1, md: 2, lg: 3 }`. Any other count is refused with the set named (objectui#11491). The flat `smColumns` / `mdColumns` / `lgColumns` / `xlColumns` keys are retired and refused by name; the breakpoint map is the one spelling (objectui#11505). | | `gap` | `0 \| 1 \| 2 \| 3 \| 4 \| 5 \| 6 \| 8 \| 10 \| 12` | Gap step between grid items; `0` is none (default `4`). Any other number is refused with the set named (objectui#11474). | | `children` | `SchemaNode \| SchemaNode[]` | Grid items. | diff --git a/content/docs/components/layout/grid.mdx b/content/docs/components/layout/grid.mdx index 92f49c20c6..d549c5b208 100644 --- a/content/docs/components/layout/grid.mdx +++ b/content/docs/components/layout/grid.mdx @@ -31,6 +31,10 @@ count used to draw no column class where it was authored: `13` lost its `md` cou two columns. A bare count above 1 is one column on a phone and that count from `md` up; an object keeps full control of every breakpoint. +The object is the only way to set a count per breakpoint. The flat `smColumns`, `mdColumns`, +`lgColumns` and `xlColumns` keys are retired and refused by name (objectui#11505): write the count +as that breakpoint's member, so `"columns": 1, "mdColumns": 2` becomes `"columns": { "xs": 1, "md": 2 }`. + `gap` is a step on the grid's spacing scale, and `0` means none. The ten steps are the ones the renderer maps to a gap class, so they are the only values validation accepts: `"gap": 9` or `"gap": 16` is refused with the set named (objectui#11474). For such a number the renderer diff --git a/examples/schema-catalog/test/grid-columns-key.test.tsx b/examples/schema-catalog/test/grid-columns-key.test.tsx index 058661acdc..c65f1654fc 100644 --- a/examples/schema-catalog/test/grid-columns-key.test.tsx +++ b/examples/schema-catalog/test/grid-columns-key.test.tsx @@ -3,8 +3,9 @@ * never `cols` (objectui#4001). * * `GridSchema` declares `columns` (`packages/types/src/layout.ts`), the `grid` - * renderer reads only `schema.columns`, and the registered designer `inputs` - * expose only `columns` / `smColumns` / `mdColumns` / `lgColumns` / `xlColumns`. + * renderer reads only `schema.columns`, and the registered `inputs` expose only + * `columns` as a column key: its breakpoint object is the one per-breakpoint + * spelling, since objectui#11505 retired the flat `smColumns` … `xlColumns`. * `cols` was never declared by the spec, the types, or the registry — nothing in * `core` / `react` / `types` normalizes it. Thirteen catalog examples spelled it * anyway, so the engine dropped the value on the floor: `baseCols` fell back to diff --git a/packages/components/src/__tests__/grid-dom-attribute-whitelist.test.tsx b/packages/components/src/__tests__/grid-dom-attribute-whitelist.test.tsx index 73ac200888..49de45450a 100644 --- a/packages/components/src/__tests__/grid-dom-attribute-whitelist.test.tsx +++ b/packages/components/src/__tests__/grid-dom-attribute-whitelist.test.tsx @@ -67,15 +67,19 @@ const isAllowedAttribute = (name: string): boolean => /** * A grid node carrying, deliberately, one of every category the leak drew from: - * the renderer's own vocabulary in both `columns` spellings, SDUI node metadata, - * the `props` container, an unknown authored key, and the legitimately forwarded - * DOM channels. + * the renderer's own vocabulary, the four flat column keys it used to read, + * SDUI node metadata, the `props` container, an unknown authored key, and the + * legitimately forwarded DOM channels. */ const CANARY_NODE = { type: 'grid', // Renderer vocabulary — CONSUMED off `schema`, never forwarded. columns: 4, gap: 4, + // The flat column keys, retired by objectui#11505: both zod faces refuse + // them by name, and the renderer no longer reads them. They stay in the + // canary because a document that skipped validation still carries them, and + // they must reach the DOM no more than any other key the renderer ignores. smColumns: 2, mdColumns: 2, lgColumns: 3, @@ -131,12 +135,11 @@ describe('ui:grid — schema keys never reach the DOM (objectui#4787)', () => { }); it('case 1b: a responsive `columns` OBJECT does not become columns="[object Object]"', () => { - // The flat `smColumns`/`mdColumns`/… keys deliberately OVERRIDE the object form - // in this renderer, so they are cleared here to exercise the object path itself. - const el = renderCanary({ - columns: { xs: 1, md: 3 }, - smColumns: undefined, mdColumns: undefined, lgColumns: undefined, xlColumns: undefined, - }); + // The canary's flat `smColumns`/`mdColumns`/… keys are read by nothing since + // objectui#11505, so they stay on the node: the object path drives the + // classes alone. Until then they OVERRODE the object form, and this case + // had to clear them. + const el = renderCanary({ columns: { xs: 1, md: 3 } }); expect(el.hasAttribute('columns')).toBe(false); expect(el.outerHTML).not.toContain('[object Object]'); @@ -154,9 +157,13 @@ describe('ui:grid — schema keys never reach the DOM (objectui#4787)', () => { // Computed layout classes — the renderer's real output, merged with the // authored className. A filter that dropped `className` would fail here. + // The bare `columns: 4` draws its mobile-first ramp: the canary's flat + // `mdColumns: 2` is read by nothing (objectui#11505), so `md` is the bare + // count, not the flat one. expect(el.className).toContain('grid'); expect(el.className).toContain('sm:grid-cols-2'); - expect(el.className).toContain('md:grid-cols-2'); + expect(el.className).toContain('md:grid-cols-4'); + expect(el.className).not.toContain('md:grid-cols-2'); expect(el.className).toContain('gap-4'); expect(el.className).toContain('authored-class'); diff --git a/packages/components/src/__tests__/grid-flat-columns-retired-11505.test.tsx b/packages/components/src/__tests__/grid-flat-columns-retired-11505.test.tsx new file mode 100644 index 0000000000..2f1edc51c6 --- /dev/null +++ b/packages/components/src/__tests__/grid-flat-columns-retired-11505.test.tsx @@ -0,0 +1,170 @@ +/** + * 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#11505 — the `grid` registration and renderer drop the flat + * per-breakpoint column channel: no `smColumns` / `mdColumns` / `lgColumns` / + * `xlColumns` input, no read, and a seed in the breakpoint object, the one + * spelling. + * + * ## What was split + * + * The registration offered the four keys as inputs and seeded two of them + * (`columns: 1, mdColumns: 2, lgColumns: 4`); the renderer read each one OVER + * the breakpoint object and let it switch off the bare count's mobile-first + * ramp. No face of `GridSchema` declared them. Triage's ruling A retired the + * channel; the declaration half (both faces refuse the keys by name) is pinned + * in `types/src/__tests__/grid-flat-columns-retired-11505.test.ts`. + * + * ## The pins, through the real `SchemaRenderer` + * + * - The registration publishes `columns` as its only column input. + * - The seed is the breakpoint object, with no flat key, and draws the classes + * the flat seed drew. That class list was read off the flat seed on this + * card's base, through this file's own `classesOf`; this row is green on the + * base and on the change, which is the measurement that the seed moved and + * the rendering did not. + * - Each flat key is read by nothing: a document carrying one draws exactly + * what it draws without it. + * - The migration the zod refusal prescribes draws what the flat document drew + * on the base, row by row: a bare `columns: C` beside a flat key becomes the + * object's `xs: C`, and with no `columns` the object takes `xs: 2`. + * - The SDUI manifest built from the live registry answers a flat key the way + * it answers any prop the node does not declare, `unknown-prop`; it answered + * `smColumns: 13` with `invalid-enum` while the input was registered. + * + * Module-scope import of the renderers, not `beforeAll` (AGENTS.md §测试纪律). + */ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import '../renderers'; +import { SchemaRenderer } from '@object-ui/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser'; +import type { Diagnostic, SchemaElement } from '@object-ui/sdui-parser'; + +const FLAT_KEYS = ['smColumns', 'mdColumns', 'lgColumns', 'xlColumns'] as const; + +/** The root element's classes for a `grid` document, as rendered. */ +function classesOf(doc: Record): string { + const { container, unmount } = render(); + const className = (container.firstElementChild as HTMLElement | null)?.className ?? ''; + unmount(); + return className; +} + +const withoutFlatKeys = (doc: Record) => + Object.fromEntries(Object.entries(doc).filter(([key]) => !(FLAT_KEYS as readonly string[]).includes(key))); + +const config = ComponentRegistry.getConfig('grid'); + +/** + * The manifest the running app validates against, keyed by every known + * registry tag, the way `layout-containers-declare-containment.test.tsx` + * builds it (the app's own builder in `renderers/layout/page.tsx` is + * module-private). + */ +const diagnose = (schema: Record): Diagnostic[] => { + const configs = ComponentRegistry.getKnownTypes().map((t) => { + const meta = ComponentRegistry.getMeta(t); + return { type: t, namespace: meta?.namespace, isContainer: meta?.isContainer, inputs: meta?.inputs }; + }); + const manifest = manifestFromConfigs(configs as unknown as Parameters[0]); + return validateTree(schema as SchemaElement, manifest).diagnostics; +}; + +/** + * The class list the registration's flat seed (`columns: 1`, `mdColumns: 2`, + * `lgColumns: 4`, `gap: 4`) drew on this card's base, measured through + * {@link classesOf}. + */ +const SEED_CLASSES = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'; + +/** + * Flat documents, the object document the refusal prescribes for each, and + * the classes the FLAT document drew on this card's base (read through + * {@link classesOf} there). Each row is one migration shape. + */ +const MIGRATIONS: ReadonlyArray, Record, string]> = [ + [ + 'the old seed: a bare count with two flat keys', + { columns: 1, mdColumns: 2, lgColumns: 4 }, + { columns: { xs: 1, md: 2, lg: 4 } }, + 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4', + ], + [ + 'a bare count above 1: the flat key switched its ramp off', + { columns: 4, mdColumns: 2 }, + { columns: { xs: 4, md: 2 } }, + 'grid grid-cols-4 md:grid-cols-2 gap-4', + ], + [ + 'no `columns` at all: the grid drew its default two below the flat key', + { smColumns: 3 }, + { columns: { xs: 2, sm: 3 } }, + 'grid grid-cols-2 sm:grid-cols-3 gap-4', + ], + [ + 'a breakpoint object: the flat key won at its breakpoint', + { columns: { xs: 1, md: 3 }, mdColumns: 2, xlColumns: 6 }, + { columns: { xs: 1, md: 2, xl: 6 } }, + 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-6 gap-4', + ], + [ + 'all four flat keys beside a bare count', + { columns: 2, smColumns: 1, mdColumns: 2, lgColumns: 3, xlColumns: 4 }, + { columns: { xs: 2, sm: 1, md: 2, lg: 3, xl: 4 } }, + 'grid grid-cols-2 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4', + ], +]; + +describe('`grid` reads and publishes no flat column key (objectui#11505)', () => { + it('the registration publishes `columns` as its one column input', () => { + expect(config, 'the `grid` registration').toBeDefined(); + const names = (config!.inputs ?? []).map((input) => input.name); + expect(names.filter((name) => /columns$/i.test(name))).toEqual(['columns']); + // Non-vacuity: the inputs were read, and the live neighbours are there. + expect(names).toEqual(expect.arrayContaining(['columns', 'gap', 'children'])); + }); + + it('the seed is the breakpoint object, with no flat key', () => { + const seed = (config!.defaultProps ?? {}) as Record; + expect(Object.keys(seed).filter((key) => /columns$/i.test(key))).toEqual(['columns']); + expect(seed.columns).toEqual({ xs: 1, md: 2, lg: 4 }); + }); + + it('the seeded grid draws the classes the flat seed drew', () => { + const seed = (config!.defaultProps ?? {}) as Record; + expect(classesOf(seed)).toBe(SEED_CLASSES); + }); + + it.each(MIGRATIONS)('%s — each flat key is read by nothing', (_label, flat) => { + expect(classesOf(flat)).toBe(classesOf(withoutFlatKeys(flat))); + }); + + it.each(MIGRATIONS)('%s — the prescribed object draws what the flat document drew', (_label, _flat, migrated, drew) => { + expect(classesOf(migrated)).toBe(drew); + }); + + it.each(FLAT_KEYS)('the SDUI manifest answers `%s` as a prop `grid` does not have', (key) => { + expect(diagnose({ type: 'grid', [key]: 2 }).map((d) => [d.severity, d.code, d.message])).toEqual([ + ['warning', 'unknown-prop', ` has no prop "${key}"`], + ]); + }); + + it('the SDUI manifest takes the breakpoint object those keys spelled a second time', () => { + // Lit control for the row above: the same node, in the one spelling. + expect(diagnose({ type: 'grid', columns: { xs: 1, sm: 2, md: 2, lg: 4, xl: 6 } })).toEqual([]); + }); + + it('a flat key no longer switches the bare count\'s ramp off', () => { + // Lit control for the rows above: the ramp is what a bare `4` draws. + expect(classesOf({ columns: 4 })).toBe('grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4'); + expect(classesOf({ columns: 4, mdColumns: 2 })).toBe('grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4'); + }); +}); diff --git a/packages/components/src/__tests__/layout-spacing-sets-11474.test.tsx b/packages/components/src/__tests__/layout-spacing-sets-11474.test.tsx index 95a37db4e6..9eefc29d7f 100644 --- a/packages/components/src/__tests__/layout-spacing-sets-11474.test.tsx +++ b/packages/components/src/__tests__/layout-spacing-sets-11474.test.tsx @@ -121,16 +121,20 @@ const BREAKPOINTS = Object.keys({ /** * Registration inputs the renderer reads and NEITHER face of the node's - * declaration declares (objectui#11491, measured): the tolerant face passes - * any value through (`BaseSchema` is `.passthrough()`), and the strict face - * refuses the key at every value (`unrecognized_keys`). Their lists are - * closed and held here like any other; their declaration is an open question - * on objectui#11491 (declare them on `GridSchema`, or retire the flat channel - * for the breakpoint object), so the reading is held instead, both ways: a key - * that gets declared turns its row red, and an undeclared input missing here - * turns the population row red. + * declaration declares: the tolerant face passes any value through + * (`BaseSchema` is `.passthrough()`), and the strict face refuses the key at + * every value (`unrecognized_keys`). Such a row's reading is held instead of + * its declaration, both ways: a key that gets declared turns its row red, and + * an undeclared input missing here turns the population row red. + * + * EMPTY, and the population row holds it empty. objectui#11491 measured four + * rows here, `grid`'s flat `smColumns` / `mdColumns` / `lgColumns` / + * `xlColumns`; objectui#11505 retired them (triage's ruling A): the + * registration no longer offers them, the renderer no longer reads them, and + * both faces refuse them by name, so the breakpoint object of `columns` is the + * one spelling. A row added back here is a second spelling being registered. */ -const REGISTERED_NOT_DECLARED = ['grid.lgColumns', 'grid.mdColumns', 'grid.smColumns', 'grid.xlColumns']; +const REGISTERED_NOT_DECLARED: readonly string[] = []; /* ── The compiled stylesheet ─────────────────────────────────────────────── */ diff --git a/packages/components/src/renderers/layout/grid.tsx b/packages/components/src/renderers/layout/grid.tsx index bf3b6eea0e..6f30eef828 100644 --- a/packages/components/src/renderers/layout/grid.tsx +++ b/packages/components/src/renderers/layout/grid.tsx @@ -64,8 +64,8 @@ const GRID_COLS_2XL: Record = { }; // The counts the six maps above spell, as the registration's closed list -// (objectui#11491), in `container.padding`'s `{ label, value }` form. One list, -// shared by the five column inputs below. +// (objectui#11491), in `container.padding`'s `{ label, value }` form: the +// `columns` input's own list, and the members of its breakpoint object. const COLUMN_COUNT_OPTIONS = [ { label: '1', value: 1 }, { label: '2', value: 2 }, @@ -87,7 +87,7 @@ const GAPS: Record = { }; ComponentRegistry.register('grid', - ({ schema, className, ...props }: { schema: GridSchema & { smColumns?: number, mdColumns?: number, lgColumns?: number, xlColumns?: number }; className?: string; [key: string]: any }) => { + ({ schema, className, ...props }: { schema: GridSchema; className?: string; [key: string]: any }) => { // Determine columns configuration // Supports detailed object configuration from schema let baseCols = 2; @@ -108,26 +108,17 @@ ComponentRegistry.register('grid', xxlCols = schema.columns['2xl']; } - // Fallback to legacy flat props if provided (from designer) - if (schema.smColumns) smCols = schema.smColumns; - if (schema.mdColumns) mdCols = schema.mdColumns; - if (schema.lgColumns) lgCols = schema.lgColumns; - if (schema.xlColumns) xlCols = schema.xlColumns; - - // Mobile-first ramp: a bare numeric `columns` (no explicit responsive - // overrides) collapses on small screens so an N-across row doesn't render - // as unreadable slivers on a phone. Authors who pass a responsive object - // or sm/md/lg/xlColumns keep full control. + // No flat per-breakpoint key is read (objectui#11505). `smColumns`, + // `mdColumns`, `lgColumns` and `xlColumns` were a second spelling of the + // breakpoint object above, read over it, and declared by no face of + // `GridSchema`; both faces now refuse them by name, and a value that + // reaches here unvalidated draws nothing and is never forwarded. // - // `xxlCols` is deliberately NOT in this condition. It is only ever set from - // the responsive-object branch above, which this arm cannot have taken - // (`typeof schema.columns === 'number'`), and there is no `xxlColumns` - // legacy flat prop — the designer's flat channel stays at the five it - // declares in `inputs` below. Add it here if that ever changes. - if ( - typeof schema.columns === 'number' && baseCols > 1 && - smCols === undefined && mdCols === undefined && lgCols === undefined && xlCols === undefined - ) { + // Mobile-first ramp: a bare numeric `columns` collapses on small screens so + // an N-across row doesn't render as unreadable slivers on a phone. Authors + // who pass the breakpoint object keep full control. The bare-number branch + // sets no breakpoint count, so the ramp's only condition is that branch. + if (typeof schema.columns === 'number' && baseCols > 1) { mdCols = baseCols; smCols = Math.min(2, baseCols); baseCols = 1; @@ -181,8 +172,8 @@ ComponentRegistry.register('grid', // `style` is forwarded BY NAME rather than reopened in the shared whitelist (the // objectui#4435 route): it is this container's designer sizing channel, but the // shared set is deliberately element-agnostic and nothing element-specific belongs - // in it. Grid's own keys (`columns`, `gap`, `smColumns`…) are CONSUMED off `schema` - // above and must never be forwarded. + // in it. Grid's own keys (`columns`, `gap`) are CONSUMED off `schema` above and must + // never be forwarded. const { style, ...hostProps } = props; return ( @@ -205,20 +196,18 @@ ComponentRegistry.register('grid', { namespace: 'ui', label: 'Grid Layout', - // The five column inputs are closed lists of the counts the `GRID_COLS*` - // maps above spell, not `type: 'number'` (objectui#11491): any other count - // drew no column class where it was authored. `GridSchema.columns` refuses - // the rest on both faces; these lists carry the same set into the SDUI - // manifest, so `validateTree` answers `smColumns: 13` with `invalid-enum`. - // `columns` also takes the breakpoint object `GridSchema` declares, so it - // publishes an `object` arm whose members are the same list (`of`): - // `columns: 13` is an error-level `type-mismatch` naming the list, and - // `columns: { md: 13 }` a `member-type-mismatch`. - // `layout-spacing-sets-11474.test.tsx` holds these lists, the declaration + // The column input is a closed list of the counts the `GRID_COLS*` maps + // above spell, not `type: 'number'` (objectui#11491): any other count drew + // no column class where it was authored. `GridSchema.columns` refuses the + // rest on both faces. `columns` also takes the breakpoint object + // `GridSchema` declares, so it publishes an `object` arm whose members are + // the same list (`of`): `columns: 13` is an error-level `type-mismatch` + // naming the list, and `columns: { md: 13 }` a `member-type-mismatch`. + // `layout-spacing-sets-11474.test.tsx` holds this list, the declaration // and the rendered classes the compiled stylesheet defines to one set. - // ⚠️ `smColumns` … `xlColumns` are read above but declared by NEITHER - // face of `GridSchema` (that pin's `REGISTERED_NOT_DECLARED` ledger names - // them): only their lists are closed here. + // ⛔ No flat `smColumns` … `xlColumns` input (objectui#11505): the + // breakpoint object is the one spelling of a per-breakpoint count, and + // `validateTree` answers a flat key with `unknown-prop`. inputs: [ { name: 'columns', @@ -228,30 +217,6 @@ ComponentRegistry.register('grid', description: 'Column count, 1 to 12, or an object of such counts keyed by breakpoint (xs, sm, md, lg, xl, 2xl). Default 2.' }, - { - name: 'smColumns', - type: 'enum', - enum: COLUMN_COUNT_OPTIONS, - description: 'Column count, 1 to 12, at the sm breakpoint (>640px)' - }, - { - name: 'mdColumns', - type: 'enum', - enum: COLUMN_COUNT_OPTIONS, - description: 'Column count, 1 to 12, at the md breakpoint (>768px)' - }, - { - name: 'lgColumns', - type: 'enum', - enum: COLUMN_COUNT_OPTIONS, - description: 'Column count, 1 to 12, at the lg breakpoint (>1024px)' - }, - { - name: 'xlColumns', - type: 'enum', - enum: COLUMN_COUNT_OPTIONS, - description: 'Column count, 1 to 12, at the xl breakpoint (>1280px)' - }, { name: 'gap', // A closed list, in `container.padding`'s object form, not @@ -282,9 +247,11 @@ ComponentRegistry.register('grid', { name: 'children', type: 'slot' } ], defaultProps: { - columns: 1, - mdColumns: 2, - lgColumns: 4, + // One column on a phone, two from `md`, four from `lg`: the breakpoint + // object, the one spelling (objectui#11505). The seed was `columns: 1` + // with the retired flat `mdColumns: 2` / `lgColumns: 4`, and draws the + // same classes in this form. + columns: { xs: 1, md: 2, lg: 4 }, gap: 4, children: [ { type: 'card', title: 'Card 1', description: 'First card' }, diff --git a/packages/types/src/__tests__/grid-flat-columns-retired-11505.test.ts b/packages/types/src/__tests__/grid-flat-columns-retired-11505.test.ts new file mode 100644 index 0000000000..62800a4312 --- /dev/null +++ b/packages/types/src/__tests__/grid-flat-columns-retired-11505.test.ts @@ -0,0 +1,183 @@ +/** + * 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#11505 — the `grid` node's flat per-breakpoint column keys, + * `smColumns`, `mdColumns`, `lgColumns` and `xlColumns`, are retired on both + * faces. The breakpoint object of `columns` is the one spelling. + * + * ## The split this closes + * + * The `grid` registration offered the four keys as inputs and the renderer + * read them over the breakpoint object, but no face of `GridSchema` declared + * them: the strict authoring face refused each key at every value + * (`unrecognized_keys`), and the tolerant face (`safeValidateSchema`, what + * `objectui validate` runs) passed any value through `BaseSchema`'s + * `.passthrough()`, so `mdColumns: 'wide'` validated. Triage's ruling A on the + * card: retire the flat channel, with no alias and no deprecation window. + * + * ## The pins + * + * Each key is a `?: never` tombstone on the TypeScript face and a + * `retirementTombstone` on the zod face. ⛔ Not a deletion: the index + * signature and `.passthrough()` would type it `any` and parse it green. Every + * value is refused at the key's own path on the strict face, the tolerant face + * and the mirror, and the message names the breakpoint object member to write. + * The prescription is checked by evaluation, not by reading: the migrated + * document it describes parses on both faces. That it draws the classes the + * flat document drew is the render half, pinned in + * `components/src/__tests__/grid-flat-columns-retired-11505.test.tsx`. + * + * ## Two instruments, and which half each one reads + * + * The `Expect` / `Equal` aliases and the `@ts-expect-error` directives are + * TYPE-level: `tsc -p tsconfig.test.json` (a leg of this package's + * `type-check` script) reads them. The `safeParse` / `safeValidateSchema` rows + * are RUNTIME, and vitest reads them. A green run of either alone says nothing + * about the other. + */ + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; + +import { GridSchema, StrictAnyComponentSchema, safeValidateSchema } from '../zod/index.zod.js'; +import type { GridSchema as Ts_GridSchema } from '../layout'; + +/* ── Type-level pins: the `tsc` channel ────────────────────────────────────── */ + +/** Invariant equality — `extends` both ways would accept a narrowing. */ +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +type Shape = (typeof GridSchema)['shape']; +type InputOf = Shape[K] extends z.ZodType ? z.input : never; + +/** + * Each retired key READS as `undefined` on the TypeScript face (`?: never` + * without `exactOptionalPropertyTypes` collapses to `undefined`), which + * `Equal` separates from the `any` a deletion would leave behind + * `BaseSchema`'s index signature. + */ +export type assertionRetiredKeysReadAsTombstones = [ + Expect>, + Expect>, + Expect>, + Expect>, +]; + +/** The zod face agrees: each retired key accepts nothing but absence. */ +export type assertionZodFaceAcceptsOnlyAbsence = [ + Expect, undefined>>, + Expect, undefined>>, + Expect, undefined>>, + Expect, undefined>>, +]; + +/** Non-vacuity: `columns`, the one spelling, keeps its breakpoint object on both faces. */ +export type assertionColumnsKeepsTheBreakpointObject = [ + Expect['md'], Extract, object>['md']>>, +]; + +/* ── Fixtures ──────────────────────────────────────────────────────────────── */ + +const RETIRED = { + smColumns: 'sm', + mdColumns: 'md', + lgColumns: 'lg', + xlColumns: 'xl', +} as const; + +/** + * Values the tolerant face used to pass through, unexamined: the counts the + * registration offered, a count outside them, and values no count spells. + */ +const FORMER_VALUES: readonly unknown[] = [2, 12, 13, 0, 'wide', null, { md: 2 }]; + +const NODE = { type: 'grid' as const, columns: 4 as const, children: [] }; +const UNKNOWN_KEY = 'zzzNotAKeyAnySurfaceDeclares11505'; + +type Issue = { code: string; path: PropertyKey[]; message: string }; +type Parse = { success: boolean; error?: { issues: Issue[] } }; + +const strict = StrictAnyComponentSchema as unknown as { safeParse: (v: unknown) => Parse }; +const issuesOf = (result: Parse): Issue[] => (result.success ? [] : (result.error?.issues ?? [])); +const pathsOf = (result: Parse): string[] => issuesOf(result).map((i) => i.path.map(String).join('.')); + +/* ── The `tsc` half on fresh literals ─────────────────────────────────────── */ + +describe('authoring a flat grid column key is a `tsc` error (objectui#11505)', () => { + it('refuses each of the four — presence is the error, not the value', () => { + // @ts-expect-error `smColumns` is retired (objectui#11505) — write `columns: { sm: N }` + const a: Ts_GridSchema = { type: 'grid', smColumns: 2 }; + // @ts-expect-error `mdColumns` is retired (objectui#11505) — write `columns: { md: N }` + const b: Ts_GridSchema = { type: 'grid', columns: 1, mdColumns: 2 }; + // @ts-expect-error `lgColumns` is retired (objectui#11505) — write `columns: { lg: N }` + const c: Ts_GridSchema = { type: 'grid', lgColumns: 4 }; + // @ts-expect-error `xlColumns` is retired (objectui#11505) — write `columns: { xl: N }` + const d: Ts_GridSchema = { type: 'grid', columns: { xs: 1 }, xlColumns: 6 }; + // The one spelling compiles: the control that the four rows above measure the keys. + const e: Ts_GridSchema = { type: 'grid', columns: { xs: 1, sm: 2, md: 2, lg: 4, xl: 6 } }; + expect([a, b, c, d, e].every((n) => n.type === 'grid')).toBe(true); + }); +}); + +/* ── The runtime half: refused by name, with the prescription ─────────────── */ + +describe('the zod face refuses each flat grid column key by name (objectui#11505)', () => { + for (const [key, breakpoint] of Object.entries(RETIRED)) { + it(`\`${key}\` is refused at its own path on the strict face, the tolerant face and the mirror, at every value`, () => { + for (const value of FORMER_VALUES) { + const doc = { ...NODE, [key]: value }; + const label = `${key}: ${JSON.stringify(value)}`; + expect(pathsOf(strict.safeParse(doc)), `strict, ${label}`).toEqual([key]); + expect(pathsOf(safeValidateSchema(doc) as Parse), `tolerant, ${label}`).toEqual([key]); + expect(pathsOf(GridSchema.safeParse(doc) as Parse), `mirror, ${label}`).toEqual([key]); + } + }); + + it(`\`${key}\`'s refusal is the tombstone's, on both doors, and names \`columns: { ${breakpoint}: N }\``, () => { + const doc = { ...NODE, [key]: 2 }; + for (const [face, result] of [ + ['strict', strict.safeParse(doc)], + ['tolerant', safeValidateSchema(doc) as Parse], + ] as const) { + const [issue] = issuesOf(result); + // `invalid_type`, the tombstone's code, and not the strict face's + // `unrecognized_keys`: the key is declared, and refused by name. + expect(issue?.code, face).toBe('invalid_type'); + expect(issue?.message.startsWith(`RETIRED (objectui#11505, ADR-0049) — \`${key}\` on \`grid\``), face).toBe(true); + expect(issue?.message, face).toContain('Instead:'); + expect(issue?.message, face).toContain(`\`columns: { ${breakpoint}: N }\``); + } + }); + } + + it('the prescription parses: each migrated document the refusal describes validates on both faces', () => { + // A bare `columns: C` beside a flat key becomes the object's `xs: C`; with + // no `columns`, the object takes `xs: 2`. Every breakpoint at once, too. + const migrated = [ + { type: 'grid', columns: { xs: 1, md: 2, lg: 4 } }, + { type: 'grid', columns: { xs: 2, sm: 3 } }, + { type: 'grid', columns: { xs: 4, sm: 1, md: 2, lg: 3, xl: 6 } }, + ]; + for (const doc of migrated) { + expect(issuesOf(strict.safeParse(doc)), JSON.stringify(doc)).toEqual([]); + expect(issuesOf(safeValidateSchema(doc) as Parse), JSON.stringify(doc)).toEqual([]); + } + }); + + it('lit control: the node without the keys parses on every face, and an undeclared key stays green on the tolerant one', () => { + expect(issuesOf(strict.safeParse(NODE))).toEqual([]); + expect(issuesOf(safeValidateSchema(NODE) as Parse)).toEqual([]); + expect(issuesOf(safeValidateSchema({ ...NODE, [UNKNOWN_KEY]: 2 }) as Parse)).toEqual([]); + // … and the strict face refuses that undeclared key as `unrecognized_keys`, + // the answer the four retired keys got before they were declared refusals. + expect(issuesOf(strict.safeParse({ ...NODE, [UNKNOWN_KEY]: 2 })).map((i) => i.code)).toEqual(['unrecognized_keys']); + }); +}); diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts index 370c874414..5f8d2355e1 100644 --- a/packages/types/src/layout.ts +++ b/packages/types/src/layout.ts @@ -859,6 +859,35 @@ export interface GridSchema extends BaseSchema { * @default 2 */ columns?: GridColumnCount | Partial>; + /** + * RETIRED (objectui#11505, ADR-0049) — a second spelling of the `sm` member + * of `columns`. Write the count in the breakpoint object, `columns: { sm: N }`; + * a bare `columns: C` beside it becomes the object's `xs: C`, and with no + * `columns` at all add `xs: 2` to keep the two columns the grid drew below `sm`. + * @deprecated The renderer no longer reads it; the zod mirror refuses it by name. + */ + smColumns?: never; + /** + * RETIRED (objectui#11505, ADR-0049) — a second spelling of the `md` member + * of `columns`. Write `columns: { md: N }`, as {@link GridSchema.smColumns} + * describes for `sm`. + * @deprecated The renderer no longer reads it; the zod mirror refuses it by name. + */ + mdColumns?: never; + /** + * RETIRED (objectui#11505, ADR-0049) — a second spelling of the `lg` member + * of `columns`. Write `columns: { lg: N }`, as {@link GridSchema.smColumns} + * describes for `sm`. + * @deprecated The renderer no longer reads it; the zod mirror refuses it by name. + */ + lgColumns?: never; + /** + * RETIRED (objectui#11505, ADR-0049) — a second spelling of the `xl` member + * of `columns`. Write `columns: { xl: N }`, as {@link GridSchema.smColumns} + * describes for `sm`. + * @deprecated The renderer no longer reads it; the zod mirror refuses it by name. + */ + xlColumns?: never; /** * Gap step between items; `0` means none. * diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index 1e1988967c..b761015113 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -701,6 +701,32 @@ const GRID_GAP_STEPS = [0, 1, 2, 3, 4, 5, 6, 8, 10, 12] as const; */ const GRID_COLUMN_COUNTS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const; +/** + * The objectui#11505 retirement guidance for the four flat per-breakpoint + * column keys of the `grid` node. One concept, responsive columns, has one + * spelling: the breakpoint object of `columns`. The refusal names the key, the + * breakpoint it spelled a second time, and the object member to write instead. + * The TypeScript face carries the same four keys as `?: never` tombstones. + * + * The migration it prescribes is the one that draws the same classes, measured + * through the real `SchemaRenderer` on objectui#11505: a flat key switched off + * the bare count's mobile-first ramp, so a bare `columns: C` beside it is the + * object's `xs: C`; with no `columns` at all the grid drew its default two + * columns below that breakpoint, and an object without `xs` draws one, so the + * object needs `xs: 2` to keep them. + */ +const retiredGridColumnsKey = (key: string, breakpoint: 'sm' | 'md' | 'lg' | 'xl') => + retirementTombstone( + `RETIRED (objectui#11505, ADR-0049) — \`${key}\` on \`grid\` was a second spelling of the ` + + `\`${breakpoint}\` member of \`columns\`: the registration offered it and the renderer read it over ` + + 'the breakpoint object, but no face of `GridSchema` declared it, so the strict face refused it at ' + + 'every value and the tolerant face passed any value through unexamined. The renderer no longer reads it. ' + + `Instead: write the count, 1 to 12, as the \`${breakpoint}\` member of the breakpoint object, ` + + `\`columns: { ${breakpoint}: N }\`, and delete the key. A bare \`columns: C\` beside it becomes the ` + + "object's `xs: C`; with no `columns` at all, add `xs: 2` to keep the two columns the grid drew below " + + `\`${breakpoint}\`.`, + ); + /** * Grid Schema - CSS Grid layout component */ @@ -748,6 +774,14 @@ export const GridSchema = BaseSchema.extend({ error: (issue) => (issue.code === 'invalid_union' ? refusal : undefined), }), ), + // ADR-0049 RETIREMENT TOMBSTONES (objectui#11505) — see + // {@link retiredGridColumnsKey}. A plain deletion would let each key ride + // `BaseSchema.passthrough()` on the tolerant face, unexamined, so each is + // refused by name on both faces instead. + smColumns: retiredGridColumnsKey('smColumns', 'sm'), + mdColumns: retiredGridColumnsKey('mdColumns', 'md'), + lgColumns: retiredGridColumnsKey('lgColumns', 'lg'), + xlColumns: retiredGridColumnsKey('xlColumns', 'xl'), // A literal union of the renderer's mapped steps, not `z.number()` // (objectui#11474) — see {@link GRID_GAP_STEPS}. gap: rendererMappedSet({ diff --git a/scripts/check-component-surface-parity.mjs b/scripts/check-component-surface-parity.mjs index 204c904083..99f8b4ea1a 100644 --- a/scripts/check-component-surface-parity.mjs +++ b/scripts/check-component-surface-parity.mjs @@ -814,7 +814,7 @@ function schemaTypeNameOf(fn, localTypes) { if (!typeNode) return null; if (ts.isTypeReferenceNode(typeNode) && ts.isIdentifier(typeNode.typeName)) return typeNode.typeName.text; - // `GridSchema & { smColumns?: number }` -- the named half is the interface. + // `UIActionSchema & { actionType?: string }` -- the named half is the interface. if (ts.isIntersectionTypeNode(typeNode)) { for (const member of typeNode.types) { const named = nameOfTypeNode(member);