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
2 changes: 2 additions & 0 deletions .changeset/11441-known-types-retired-layout-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions .changeset/11491-grid-columns-set.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
34 changes: 34 additions & 0 deletions .changeset/11505-grid-flat-columns-retired.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions .changeset/7097-grid-2xl-breakpoint.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions .changeset/8516-8556-mirror-partial-record-narrowing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<…>>`, 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.
2 changes: 1 addition & 1 deletion content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ A responsive grid layout. Columns can be a fixed number or responsive breakpoint

| Property | Type | Description |
|----------|------|-------------|
| `columns` | `ColumnCount \| Partial<Record<BreakpointName, ColumnCount>>` | 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<Record<BreakpointName, ColumnCount>>` | 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. |

Expand Down
4 changes: 4 additions & 0 deletions content/docs/components/layout/grid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions examples/schema-catalog/test/grid-columns-key.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]');
Expand All @@ -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');

Expand Down
Original file line number Diff line number Diff line change
@@ -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, unknown>): string {
const { container, unmount } = render(<SchemaRenderer schema={{ type: 'grid', children: [], ...doc } as never} />);
const className = (container.firstElementChild as HTMLElement | null)?.className ?? '';
unmount();
return className;
}

const withoutFlatKeys = (doc: Record<string, unknown>) =>
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<string, unknown>): 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<typeof manifestFromConfigs>[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<readonly [string, Record<string, unknown>, Record<string, unknown>, 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<string, unknown>;
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<string, unknown>;
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', `<grid> 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');
});
});
Loading
Loading