From 9b7aaf76d4ec3082689145aa3bac0f9210715e3a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:05:43 +0000 Subject: [PATCH 1/2] fix(types,components,core)!: grid columns is one of the renderer's counts, 1 to 12, bare and at every breakpoint; the family pin holds every numeric layout input (objectui#11491) GridSchema.columns closes to 1..12 on both faces, for the bare number and every breakpoint of the object form; the refusal names the set. The grid registration's columns / smColumns / mdColumns / lgColumns / xlColumns inputs become closed enums (columns also publishes the breakpoint object, members closed through `of`). The renderer's `|| 'grid-cols-2'` base fallback is removed: no validated document reaches an unmapped base count. GridBuilder.columns() takes the declared counts. The objectui#11474 enumeration pin is extended in place to every numeric layout input, counts included, with a ledger for the registration inputs no face declares. The shared zod helper is renamed rendererMappedSet. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../11441-known-types-retired-layout-keys.md | 2 + ...11441-retire-nav-responsive-grid-layout.md | 2 + .changeset/11491-grid-columns-set.md | 41 +++ .../8505-grid-columns-breakpoint-narrowing.md | 2 + content/docs/api/schema-reference.md | 4 +- content/docs/components/layout/grid.mdx | 12 +- .../layout-spacing-sets-11474.test.tsx | 326 +++++++++++++----- .../components/src/renderers/layout/grid.tsx | 91 +++-- .../builder/__tests__/schema-builder.test.ts | 10 + packages/core/src/builder/schema-builder.ts | 6 +- ...-columns-breakpoint-narrowing-8505.test.ts | 35 +- .../__tests__/grid-columns-set-11491.test.ts | 169 +++++++++ ...rror-partial-record-narrowing-8516.test.ts | 7 +- packages/types/src/layout.ts | 20 +- packages/types/src/zod/layout.zod.ts | 149 +++++--- 15 files changed, 717 insertions(+), 159 deletions(-) create mode 100644 .changeset/11491-grid-columns-set.md create mode 100644 packages/types/src/__tests__/grid-columns-set-11491.test.ts diff --git a/.changeset/11441-known-types-retired-layout-keys.md b/.changeset/11441-known-types-retired-layout-keys.md index 9c468d1790..e71ef9a082 100644 --- a/.changeset/11441-known-types-retired-layout-keys.md +++ b/.changeset/11441-known-types-retired-layout-keys.md @@ -14,3 +14,5 @@ Migration: The registered-types ratchet (`REFUSED_AT_TYPE`) falls from 20 to 18, and its namespaced twin from 374 to 372. **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. diff --git a/.changeset/11441-retire-nav-responsive-grid-layout.md b/.changeset/11441-retire-nav-responsive-grid-layout.md index e967d2b7a6..52e40a9357 100644 --- a/.changeset/11441-retire-nav-responsive-grid-layout.md +++ b/.changeset/11441-retire-nav-responsive-grid-layout.md @@ -17,3 +17,5 @@ Migration, measured against `objectui validate` on both of its faces: **Clause-②: yes** — two registrations leave the runtime (narrowing), released as `minor` with this banner. ⚠️ **Dated note, 2026-10-02 — `grid` takes one of ten `gap` steps, not any number — objectui#11474.** At this change `grid` accepted any `gap` number; now it accepts 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 `G` at `gap` on both faces with that set named: 7, 9, 11, a number above 12, a negative number or a fraction. For such a number `grid` drew no gap anyway, because the class it built at runtime is in no compiled stylesheet. So in the migration above `G` must be one of those ten steps; each step `ResponsiveGrid`'s own class map drew (0 to 6 and 8) is one of them. `.changeset/11474-layout-spacing-sets.md` states what ships. The rest of this entry is kept as the reading of this change. + +⚠️ **Dated note, 2026-10-02 — `grid` takes column counts 1 to 12, not any number — objectui#11491.** At this change `grid` accepted any count in the breakpoint `columns` object; now each count is one of 1 to 12, the counts the `grid` renderer maps, and `objectui validate` refuses any other count in `C` at `columns` on both faces with that set named. For such a count `grid` drew no column class at that breakpoint anyway. So in the migration above every count in `C` must be one of 1 to 12; each count `ResponsiveGrid`'s own class map drew (1, 2, 3, 4, 6 and 12) is one of them. `.changeset/11491-grid-columns-set.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 new file mode 100644 index 0000000000..af4fa85f7a --- /dev/null +++ b/.changeset/11491-grid-columns-set.md @@ -0,0 +1,41 @@ +--- +'@object-ui/types': minor +'@object-ui/components': minor +'@object-ui/core': minor +--- + +The `columns` of a `grid` node is one of the counts its renderer maps, 1 to 12, as the bare +number and at every breakpoint of the object form. Any other count is refused at validation, +with the set named (objectui#11491). + +**Breaking for a `grid` that carries any other column count.** The key was declared as any +number, at the bare number and at every breakpoint. But the renderer has one column class per +count from 1 to 12 at each breakpoint and nothing for the rest, so such a count drew no column +class where it was authored: `{ "type": "grid", "columns": 13 }` drew one column on a phone and +two from `sm` up, and never its `md` count; `{ "columns": { "md": 13 } }` drew nothing at `md`; +and `{ "columns": { "xs": 13 } }`, `"columns": 0` and `"columns": -1` drew two columns, a count +nobody authored. + +- `@object-ui/types`: `GridSchema.columns` is the literal union of 1 to 12, or a partial + breakpoint map of that union, on the TypeScript face, so `tsc` refuses any other count, and a + computed `Record` no longer assigns to it. The zod mirror refuses one at + `columns` as an `invalid_union` whose message lists the set; its arms carry the set as + `values`, at the breakpoint's own path for the object form. `safeValidateSchema` (what + `objectui validate` runs) and the strict authoring face both give that refusal. An unknown + breakpoint key is still reported on its own, as `unrecognized_keys`. +- `@object-ui/components`: the `grid` registration's `columns`, `smColumns`, `mdColumns`, + `lgColumns` and `xlColumns` inputs change from `type: 'number'` to a closed `enum` of the + twelve counts, in the object form the `container` registration's `padding` uses. `columns` + also publishes the breakpoint object the declaration takes, as an `object` arm whose members + (`of`) are the same list. In the SDUI manifest, `validateTree` now answers `smColumns: 13` with + `invalid-enum`, `columns: 13` with an error-level `type-mismatch` that lists the counts, and + `columns: { md: 13 }` with `member-type-mismatch`. A breakpoint object of mapped counts, which + drew a `type-mismatch` warning there, is now accepted, as both declaration faces already + accepted it. The renderer no longer draws `grid-cols-2` for a base count it does not map: + every count a validated document can carry is mapped, and an unmapped one that reaches it + unvalidated draws no base column class, as at every other breakpoint. Nothing rounds or + clamps. +- `@object-ui/core`: `GridBuilder.columns()` takes the declared counts instead of any number. + +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`. diff --git a/.changeset/8505-grid-columns-breakpoint-narrowing.md b/.changeset/8505-grid-columns-breakpoint-narrowing.md index f67595fc69..a854875678 100644 --- a/.changeset/8505-grid-columns-breakpoint-narrowing.md +++ b/.changeset/8505-grid-columns-breakpoint-narrowing.md @@ -52,3 +52,5 @@ The command is `objectui validate`, and `check` does not refuse `{ xxl: 6 }`: it an advisory sweep that never parses against the schema a file whose root carries a structural key (`children`, `className`, `body`, …), lists a file with none of those keys by name when the file does not validate, and exits non-zero on unreadable JSON only. + +⚠️ **Dated note, 2026-10-02 — the column counts are closed too — objectui#11491.** At this change `GridSchema.columns` was `number | Partial>`, and a computed `Record` still assigned to it. Now each count, the bare number and every breakpoint's, is one of 1 to 12, the counts the `grid` renderer maps to a column class, on the TypeScript face and in the zod mirror. A computed record still assigns when its values are typed as those counts (the key escape described above stands); a `Record` no longer does. `.changeset/11491-grid-columns-set.md` states what ships. 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 80f1df9000..6d3701f414 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -246,8 +246,8 @@ A responsive grid layout. Columns can be a fixed number or responsive breakpoint | Property | Type | Description | |----------|------|-------------| -| `columns` | `number \| Partial>` | Number of columns, or a responsive map keyed by breakpoint (`xs`, `sm`, `md`, `lg`, `xl`, `2xl`), e.g. `{ sm: 1, md: 2, lg: 3 }`. | -| `gap` | `number` | Gap between grid items (Tailwind spacing scale). | +| `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). | +| `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. | **Related:** [DivSchema](#divschema), [CardSchema](#cardschema), [DashboardComponentSchema](#dashboardcomponentschema) diff --git a/content/docs/components/layout/grid.mdx b/content/docs/components/layout/grid.mdx index 1b1514d136..92f49c20c6 100644 --- a/content/docs/components/layout/grid.mdx +++ b/content/docs/components/layout/grid.mdx @@ -12,15 +12,25 @@ description: "Responsive grid layout container" ```ts import type { SchemaNode } from '@object-ui/types'; +type ColumnCount = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + interface GridSchema { type: 'grid'; - columns?: number; + columns?: ColumnCount | Partial>; // default: 2 gap?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12; // default: 4 children: SchemaNode[]; className?: string; } ``` +`columns` is a column count from 1 to 12, or an object of such counts keyed by breakpoint +(`{ "xs": 1, "md": 2, "lg": 4 }`). Those twelve counts are the ones the renderer maps to a column +class, at every breakpoint, so they are the only values validation accepts: `"columns": 13`, +`"columns": 0` or `"columns": { "md": 13 }` is refused with the set named (objectui#11491). Such a +count used to draw no column class where it was authored: `13` lost its `md` count, and `0` drew +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. + `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/packages/components/src/__tests__/layout-spacing-sets-11474.test.tsx b/packages/components/src/__tests__/layout-spacing-sets-11474.test.tsx index a5a7200698..95a37db4e6 100644 --- a/packages/components/src/__tests__/layout-spacing-sets-11474.test.tsx +++ b/packages/components/src/__tests__/layout-spacing-sets-11474.test.tsx @@ -7,22 +7,33 @@ */ /** - * Every layout spacing key is ONE set in three places (objectui#11474, which - * generalised objectui#11424's `container.padding` pin into this file): the - * steps the renderer maps to a class the stylesheet defines, the set the zod - * declaration accepts at the authoring doors, and the closed `enum` the - * registration publishes into the SDUI manifest. + * Every numeric layout input is ONE set in three places: the values the + * renderer maps to a class the stylesheet defines, the set the zod declaration + * accepts at the authoring doors, and the closed `enum` the registration + * publishes into the SDUI manifest. + * + * objectui#11474 generalised objectui#11424's `container.padding` pin into this + * file for the SPACING keys and classified the rest as "not spacing", holding + * them to nothing. objectui#11491 extended it in place to EVERY numeric layout + * input, the `grid` column counts among them. The file name is the family's + * (objectui#11474's), kept so the pointers to it stay true; its scope is this + * header's. * * ## What is enumerated — derived, not listed * * Every registration this package makes that declares LAYOUT containment - * (`isContainer`), and every input on it whose values are numbers (`type: - * 'number'`, or an `enum` of numbers). Each such input is rendered through the - * real `SchemaRenderer` across a range of candidates; it is a SPACING key when - * its value moves a spacing utility (`gap-*`, `p-*`, `m-*`, `space-*`) on the - * element. Inputs that move none (`aspect-ratio.ratio`, `grid.columns`) are - * classified here and held to nothing else. A new layout node with a numeric - * spacing input joins the enumeration the day it is registered. + * (`isContainer`), and every input on it whose value can be a number: a + * `number` arm, or an `enum` arm of numbers (an input may declare several + * arms). Each such input is one POSITION; an input that also publishes an + * `object` arm takes an object keyed by breakpoint (`grid.columns`), and adds + * one position per breakpoint. Each position is rendered through the real + * `SchemaRenderer` across a range of candidates and classified by the classes + * its value moves: SPACING (`gap-*`, `p-*`, `m-*`, `space-*`), COUNT + * (`grid-cols-*`), or OTHER when it moves no class at all + * (`aspect-ratio.ratio`, which sizes through style); OTHER is held to nothing + * else. A position whose value moves a class of neither family fails as + * unclassified, so a new family is decided rather than skipped. A new layout + * node with a numeric input joins the enumeration the day it is registered. * * ## What "mapped" means — the compiled stylesheet decides * @@ -32,23 +43,34 @@ * source text. `grid` builds `gap-[N*0.25rem]` at runtime for a number outside * its map, and no stylesheet carries that rule (measured on objectui#11474), so * a class string on the element is not the test. A candidate is MAPPED when the - * spacing utilities its value draws are all rules in this package's own - * compiled stylesheet (`src/index.css`, the sheet it ships as `style.css`, - * compiled here exactly as `scripts/build-css.mjs` compiles it). This file - * lives under `__tests__/`, which that sheet does not scan, so the class names - * written below cannot create the rules they look for. + * classes its value draws are all rules in this package's own compiled + * stylesheet (`src/index.css`, the sheet it ships as `style.css`, compiled here + * exactly as `scripts/build-css.mjs` compiles it) AND one of them spells the + * value (`gap-2`, `md:grid-cols-12`). A value that draws only classes other + * values spell is not mapped: a bare `13` draws `grid`'s mobile-first ramp, + * `grid-cols-1 sm:grid-cols-2`, and no count of its own. This file lives under + * `__tests__/`, which that sheet does not scan, so the class names written + * below cannot create the rules they look for. * - * ⛔ An unmapped number draws no spacing at all — the renderers neither round - * nor clamp it, on purpose; the declaration refuses it instead. + * ⛔ An unmapped number draws no class of its own — the renderers neither + * round, clamp nor substitute it, on purpose; the declaration refuses it + * instead. What it may still draw is a class several mapped values share (the + * ramp), never one that only some other single value draws. * - * ## What each spacing key is held to + * ## What each mapped-set position is held to * * - its zod declaration, at the authored spelling (flat on the node, or in the - * `properties` bag when the node refuses it flat, as `flex` does), accepts - * exactly the mapped set on the tolerant face (`safeValidateSchema`, what - * `objectui validate` runs) and on the strict authoring face; - * - its registration input is a closed `enum` of exactly the mapped set; - * - an absent key draws exactly what the registration's default step draws. + * `properties` bag when the node refuses it flat, as `flex` does; the + * breakpoint object for a breakpoint position), accepts exactly the mapped + * set on the tolerant face (`safeValidateSchema`, what `objectui validate` + * runs) and on the strict authoring face — except an input in + * {@link REGISTERED_NOT_DECLARED}, whose reading is held instead; + * - its registration input publishes a closed `enum` of exactly the mapped + * set, and a breakpoint position's input publishes an `object` arm whose + * members (`of`) are that `enum`; + * - an absent value draws no utility of its family the stylesheet lacks; a + * spacing key's absent value draws exactly what the registration's default + * step draws. * * Module-scope import of the renderers, not `beforeAll` (AGENTS.md §测试纪律). */ @@ -62,17 +84,54 @@ import { render } from '@testing-library/react'; import '../renderers'; import { SchemaRenderer } from '@object-ui/react'; import { ComponentRegistry } from '@object-ui/core'; +import type { BreakpointName } from '@object-ui/types'; import { safeValidateSchema, StrictAnyComponentSchema } from '@object-ui/types/zod'; -/** Every candidate the derivation renders: a range past the widest step, plus fractions and a negative. */ +/** Every candidate the derivation renders: a range past the widest set, plus fractions and a negative. */ const CANDIDATES = [...Array.from({ length: 33 }, (_, i) => i), -1, 0.5, 1.5, 2.5, 9.5]; /** A spacing utility at any variant: `gap-2`, `sm:gap-3`, `md:p-0.5`, `mx-auto`, `gap-[2.25rem]`. */ const isSpacingUtility = (token: string) => /^(?:[\w-]+:)*-?(?:gap(?:-[xy])?|p[xytrblse]?|m[xytrblse]?|space-[xy])-/.test(token); +/** A column-count utility at any variant: `grid-cols-3`, `2xl:grid-cols-12`. */ +const isColumnUtility = (token: string) => /^(?:[\w-]+:)*grid-cols-/.test(token); + +/** Does the utility spell this value? `md:grid-cols-12` spells 12, `gap-1.5` spells 1.5. */ +const spells = (token: string, n: number) => { + const utility = token.replace(/^(?:[\w-]+:)*/, ''); + return utility.slice(utility.lastIndexOf('-') + 1) === String(n); +}; + const sortNumbers = (values: Iterable) => [...values].map(Number).sort((a, b) => a - b); +/** + * The breakpoint vocabulary an object-arm position is keyed by. `satisfies` + * makes it exhaustive both ways at compile time: a missing or an extra name + * fails `tsc -p tsconfig.test.json`. + */ +const BREAKPOINTS = Object.keys({ + xs: 0, + sm: 0, + md: 0, + lg: 0, + xl: 0, + '2xl': 0, +} satisfies Record) as BreakpointName[]; + +/** + * 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. + */ +const REGISTERED_NOT_DECLARED = ['grid.lgColumns', 'grid.mdColumns', 'grid.smColumns', 'grid.xlColumns']; + /* ── The compiled stylesheet ─────────────────────────────────────────────── */ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); @@ -107,14 +166,17 @@ interface LayoutInput { /** The authored type: the registry key without its namespace. */ type: string; key: string; - input: { type?: unknown; enum?: unknown[] }; + input: { type?: unknown; enum?: unknown[]; of?: unknown }; defaultValue: unknown; } const enumValues = (input: LayoutInput['input']) => (input.enum ?? []).map((e) => (typeof e === 'object' && e !== null ? (e as { value: unknown }).value : e)); -/** Every numeric input on every registered layout container, once per registration. */ +/** The arms an input declares: one, or an array of them. */ +const armsOf = (declared: unknown): unknown[] => (Array.isArray(declared) ? declared : [declared]); + +/** Every input with a numeric arm on every registered layout container, once per registration. */ const NUMERIC_LAYOUT_INPUTS: LayoutInput[] = (() => { const seen = new Set(); const out: LayoutInput[] = []; @@ -125,9 +187,11 @@ const NUMERIC_LAYOUT_INPUTS: LayoutInput[] = (() => { const prefix = config.namespace ? `${config.namespace}:` : ''; const type = prefix && config.type.startsWith(prefix) ? config.type.slice(prefix.length) : config.type; for (const input of config.inputs ?? []) { + const arms = armsOf(input.type); const values = enumValues(input as LayoutInput['input']); const numeric = - input.type === 'number' || (input.type === 'enum' && values.length > 0 && values.every((v) => typeof v === 'number')); + arms.includes('number') || + (arms.includes('enum') && values.length > 0 && values.every((v) => typeof v === 'number')); if (!numeric) continue; out.push({ type, @@ -140,36 +204,92 @@ const NUMERIC_LAYOUT_INPUTS: LayoutInput[] = (() => { return out.sort((a, b) => `${a.type}.${a.key}`.localeCompare(`${b.type}.${b.key}`)); })(); +/** One place a numeric input's value is authored: the input itself, or one breakpoint of its object arm. */ +interface Position { + entry: LayoutInput; + name: string; + breakpoint?: BreakpointName; + /** The node props carrying `n` at this position, as rendered. */ + at: (n: unknown) => Record; + /** The node props with no value at this position. */ + absent: Record; +} + +const positionsOf = (entry: LayoutInput): Position[] => { + const own: Position = { + entry, + name: `${entry.type}.${entry.key}`, + at: (n) => ({ [entry.key]: n }), + absent: {}, + }; + if (!armsOf(entry.input.type).includes('object')) return [own]; + return [ + own, + ...BREAKPOINTS.map((bp) => ({ + entry, + name: `${entry.type}.${entry.key}.${bp}`, + breakpoint: bp, + at: (n: unknown) => ({ [entry.key]: { [bp]: n } }), + absent: { [entry.key]: {} }, + })), + ]; +}; + +const POSITIONS: Position[] = NUMERIC_LAYOUT_INPUTS.flatMap(positionsOf); + /* ── Rendering ───────────────────────────────────────────────────────────── */ -function spacingOf(type: string, extra: Record): string[] { +function classesOf(type: string, extra: Record): string[] { const { container, unmount } = render(); - const tokens = ((container.firstElementChild as HTMLElement | null)?.className ?? '') - .split(/\s+/) - .filter(isSpacingUtility); + const tokens = ((container.firstElementChild as HTMLElement | null)?.className ?? '').split(/\s+/).filter(Boolean); unmount(); return tokens; } +type Family = 'spacing' | 'count' | 'other' | 'unclassified'; + interface Derivation { - /** The spacing utilities each candidate's value draws, beyond those every candidate draws. */ + /** The classes each candidate's value draws, beyond those every candidate draws. */ drawn: Map; - /** Candidates whose drawn utilities exist and are all rules in the compiled sheet. */ + /** Candidates whose drawn classes are all rules in the compiled sheet, one of them spelling the candidate. */ mapped: number[]; - /** Spacing utilities every candidate draws (`mx-auto` on a centred container) — not the key's. */ - constant: string[]; + /** Drawn classes at least two mapped candidates share (`grid`'s ramp) — not any one value's. */ + shared: string[]; + family: Family; } -async function derive({ type, key }: LayoutInput): Promise { - const defined = await definedClasses(); - const raw = new Map(CANDIDATES.map((n) => [n, spacingOf(type, { [key]: n })])); - const constant = [...raw.values()].reduce((acc, tokens) => acc.filter((t) => tokens.includes(t))); - const drawn = new Map([...raw].map(([n, tokens]) => [n, tokens.filter((t) => !constant.includes(t))])); - const mapped = CANDIDATES.filter((n) => { - const tokens = drawn.get(n)!; - return tokens.length > 0 && tokens.every((t) => defined.has(t)); - }); - return { drawn, mapped, constant }; +const derivations = new Map>(); + +function derive(position: Position): Promise { + let derivation = derivations.get(position.name); + if (!derivation) { + derivation = (async () => { + const defined = await definedClasses(); + const { type } = position.entry; + const raw = new Map(CANDIDATES.map((n) => [n, classesOf(type, position.at(n))])); + const constant = [...raw.values()].reduce((acc, tokens) => acc.filter((t) => tokens.includes(t))); + const drawn = new Map([...raw].map(([n, tokens]) => [n, tokens.filter((t) => !constant.includes(t))])); + const mapped = CANDIDATES.filter((n) => { + const tokens = drawn.get(n)!; + return tokens.length > 0 && tokens.every((t) => defined.has(t)) && tokens.some((t) => spells(t, n)); + }); + const uses = new Map(); + for (const n of mapped) for (const t of drawn.get(n)!) uses.set(t, (uses.get(t) ?? 0) + 1); + const shared = [...uses].filter(([, count]) => count > 1).map(([t]) => t); + const moved = [...new Set([...drawn.values()].flat())]; + const family: Family = + moved.length === 0 + ? 'other' + : moved.every(isSpacingUtility) + ? 'spacing' + : moved.every(isColumnUtility) + ? 'count' + : 'unclassified'; + return { drawn, mapped, shared, family }; + })(); + derivations.set(position.name, derivation); + } + return derivation; } /* ── The authoring doors ─────────────────────────────────────────────────── */ @@ -180,80 +300,122 @@ const FACES = { } as const; /** - * The spelling the node is authored in: flat on the node when the tolerant - * face takes the registration's default step there, otherwise in the - * `properties` bag (`flex`, objectui#11276). Derived from the declaration. + * The spelling the input is authored in: flat on the node when the tolerant + * face takes the registration's default there, otherwise in the `properties` + * bag (`flex`, objectui#11276). Derived from the declaration. */ -function authoredSpelling({ type, key, defaultValue }: LayoutInput): (n: unknown) => Record { - const flat = (n: unknown) => ({ type, [key]: n }); - const bag = (n: unknown) => ({ type, properties: { [key]: n } }); - if (FACES.tolerant(flat(defaultValue))) return flat; - expect(FACES.tolerant(bag(defaultValue)), `${type}.${key}: no authored spelling takes the default ${String(defaultValue)}`).toBe(true); +function authoredSpelling({ type, key, defaultValue }: LayoutInput): (props: Record) => Record { + const flat = (props: Record) => ({ type, ...props }); + const bag = (props: Record) => ({ type, properties: props }); + if (FACES.tolerant(flat({ [key]: defaultValue }))) return flat; + expect(FACES.tolerant(bag({ [key]: defaultValue })), `${type}.${key}: no authored spelling takes the default ${String(defaultValue)}`).toBe(true); return bag; } +/** The strict face refuses the input's key itself, at a mapped value: no face declares it. */ +function undeclared(position: Position, mapped: number[]): boolean { + const result = StrictAnyComponentSchema.safeParse(authoredSpelling(position.entry)(position.at(mapped[0]))); + if (result.success) return false; + return result.error.issues.every( + (issue) => issue.code === 'unrecognized_keys' && issue.keys.includes(position.entry.key), + ); +} + /* ── The pins ────────────────────────────────────────────────────────────── */ -describe('layout spacing keys: the rendered set, the declared set and the registered set are one (objectui#11474)', () => { +describe('numeric layout inputs: the rendered set, the declared set and the registered set are one (objectui#11474, objectui#11491)', () => { it('the instrument sees the stylesheet: mapped utilities, variants and arbitrary values are all readable', async () => { const defined = await definedClasses(); expect(defined.size).toBeGreaterThan(800); // A variant and an escaped dot read back unescaped … expect(defined.has('sm:gap-2')).toBe(true); expect(defined.has('gap-1.5')).toBe(true); + // … a digit-led variant does too (`\32 xl\:` in the sheet) … + expect(defined.has('2xl:grid-cols-12')).toBe(true); // … and an arbitrary value is readable at all, so an absent `gap-[…]` is a real absence. expect([...defined].some((c) => /^[\w-]+-\[[^\]]+\]$/.test(c))).toBe(true); }, 60_000); - it('the enumeration is not vacuous: it finds numeric layout inputs, spacing keys among them', async () => { + it('the enumeration is not vacuous: it finds numeric layout inputs of every family, and the object arm is expanded', async () => { expect(NUMERIC_LAYOUT_INPUTS.length).toBeGreaterThan(0); - const spacing: string[] = []; - for (const entry of NUMERIC_LAYOUT_INPUTS) { - const { drawn } = await derive(entry); - if ([...drawn.values()].some((tokens) => tokens.length > 0)) spacing.push(`${entry.type}.${entry.key}`); + const byFamily: Record = { spacing: [], count: [], other: [], unclassified: [] }; + for (const position of POSITIONS) byFamily[(await derive(position)).family].push(position.name); + // Lit controls: the key objectui#11424 closed first, and the count objectui#11491 closed. + expect(byFamily.spacing).toContain('container.padding'); + expect(byFamily.spacing.length).toBeGreaterThan(1); + expect(byFamily.count).toContain('grid.columns'); + for (const bp of BREAKPOINTS) expect(byFamily.count).toContain(`grid.columns.${bp}`); + expect(byFamily.unclassified, 'a numeric layout input moves a class of no known family').toEqual([]); + }, 120_000); + + it('the registered-but-undeclared ledger is exactly the inputs no face declares', async () => { + const found: string[] = []; + for (const position of POSITIONS.filter((p) => !p.breakpoint)) { + const { mapped } = await derive(position); + if (mapped.length > 0 && undeclared(position, mapped)) found.push(position.name); } - // Lit control: the key objectui#11424 closed first is still found by the derivation. - expect(spacing).toContain('container.padding'); - expect(spacing.length).toBeGreaterThan(1); - }, 60_000); + expect(found.sort()).toEqual([...REGISTERED_NOT_DECLARED].sort()); + }, 120_000); - for (const entry of NUMERIC_LAYOUT_INPUTS) { - const name = `${entry.type}.${entry.key}`; + for (const position of POSITIONS) { + const { entry, name } = position; - it(`${name}: when it moves a spacing utility, its declaration and registration are its mapped set`, async () => { - const { drawn, mapped } = await derive(entry); - if (![...drawn.values()].some((tokens) => tokens.length > 0)) { - // Not a spacing key: no candidate moves a spacing utility. Nothing else is held here. + it(`${name}: when it moves a class, its declaration and registration are its mapped set`, async () => { + const { drawn, mapped, shared, family } = await derive(position); + expect(family, `${name} moves a class of no known family`).not.toBe('unclassified'); + if (family === 'other') { + // Moves no class: nothing else is held here. expect(mapped).toEqual([]); return; } - // The derivation reads the defect itself: mapped steps AND unmapped numbers. + // The derivation reads the defect itself: mapped values AND unmapped numbers. expect(mapped.length, `${name}: no candidate is mapped`).toBeGreaterThan(0); expect(mapped.length, `${name}: every candidate is mapped — an open scale is not a closed set`).toBeLessThan( CANDIDATES.length, ); - // An unmapped number draws no spacing the stylesheet defines: not rounded, not clamped. + // An unmapped number draws no class of its own the stylesheet defines: not + // rounded, not clamped, not substituted. A shared class (the ramp) may stay. const defined = await definedClasses(); for (const n of CANDIDATES.filter((c) => !mapped.includes(c))) { - expect(drawn.get(n)!.filter((t) => defined.has(t)), `${name} ${n}`).toEqual([]); + expect( + drawn.get(n)!.filter((t) => defined.has(t) && !shared.includes(t)), + `${name} ${n}`, + ).toEqual([]); } // The declaration, at both authoring doors. const spelling = authoredSpelling(entry); - for (const [face, parse] of Object.entries(FACES)) { - const declared = CANDIDATES.filter((n) => parse(spelling(n))); - expect(sortNumbers(declared), `${name} on the ${face} face`).toEqual(sortNumbers(mapped)); + if (REGISTERED_NOT_DECLARED.includes(name)) { + // Held as read: the tolerant face takes every candidate through, the strict face none. + expect(CANDIDATES.filter((n) => !FACES.tolerant(spelling(position.at(n)))), `${name} on the tolerant face`).toEqual([]); + expect(CANDIDATES.filter((n) => FACES.strict(spelling(position.at(n)))), `${name} on the strict face`).toEqual([]); + } else { + for (const [face, parse] of Object.entries(FACES)) { + const declared = CANDIDATES.filter((n) => parse(spelling(position.at(n)))); + expect(sortNumbers(declared), `${name} on the ${face} face`).toEqual(sortNumbers(mapped)); + } } - // The registration, as a closed enum. - expect(entry.input.type, `${name} registration input`).toBe('enum'); + // The registration, as a closed enum; a breakpoint's members, through `of`. + const arms = armsOf(entry.input.type); + expect(arms, `${name} registration input`).toContain('enum'); expect(sortNumbers(enumValues(entry.input)), `${name} registration enum`).toEqual(sortNumbers(mapped)); + if (position.breakpoint) { + expect(arms, `${name}: the breakpoint object is published`).toContain('object'); + expect(armsOf(entry.input.of), `${name}: its members are the enum`).toContain('enum'); + } - // Control: an absent key draws exactly what the registration's default step draws. - expect(mapped).toContain(entry.defaultValue); - expect(spacingOf(entry.type, {})).toEqual(spacingOf(entry.type, { [entry.key]: entry.defaultValue })); + // Control: an absent value draws no utility of its family the stylesheet lacks … + const ofFamily = family === 'spacing' ? isSpacingUtility : isColumnUtility; + const absent = classesOf(entry.type, position.absent).filter(ofFamily); + expect(absent.filter((t) => !defined.has(t)), `${name} absent`).toEqual([]); + // … and a spacing key's absent value draws exactly the registration's default step. + if (family === 'spacing') { + expect(mapped).toContain(entry.defaultValue); + expect(absent).toEqual(classesOf(entry.type, { [entry.key]: entry.defaultValue }).filter(ofFamily)); + } }, 60_000); } }); diff --git a/packages/components/src/renderers/layout/grid.tsx b/packages/components/src/renderers/layout/grid.tsx index b8344a7b45..bf3b6eea0e 100644 --- a/packages/components/src/renderers/layout/grid.tsx +++ b/packages/components/src/renderers/layout/grid.tsx @@ -63,6 +63,24 @@ const GRID_COLS_2XL: Record = { 9: '2xl:grid-cols-9', 10: '2xl:grid-cols-10', 11: '2xl:grid-cols-11', 12: '2xl:grid-cols-12' }; +// 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. +const COLUMN_COUNT_OPTIONS = [ + { label: '1', value: 1 }, + { label: '2', value: 2 }, + { label: '3', value: 3 }, + { label: '4', value: 4 }, + { label: '5', value: 5 }, + { label: '6', value: 6 }, + { label: '7', value: 7 }, + { label: '8', value: 8 }, + { label: '9', value: 9 }, + { label: '10', value: 10 }, + { label: '11', value: 11 }, + { label: '12', value: 12 }, +]; + const GAPS: Record = { 0: 'gap-0', 1: 'gap-1', 2: 'gap-2', 3: 'gap-3', 4: 'gap-4', 5: 'gap-5', 6: 'gap-6', 8: 'gap-8', 10: 'gap-10', 12: 'gap-12' @@ -120,8 +138,16 @@ ComponentRegistry.register('grid', // Generate Tailwind grid classes const gridClass = cn( 'grid', - // Base columns - GRID_COLS[baseCols] || 'grid-cols-2', + // Base columns. No fallback class (objectui#11491): this read once ended + // `|| 'grid-cols-2'`, which drew two columns for a base count the map + // lacks (`0`, `-1`, `{ xs: 13 }`). `GridSchema.columns` now refuses every + // count outside 1–12, and each path a validated document takes lands + // `baseCols` in this map: an absent `columns` keeps the `2` above, an + // object without `xs` takes `?? 1`, and a bare count above 1 is ramped + // to `1`. An unmapped count that reaches here unvalidated draws no base + // column class, the same as an unmapped count at any other breakpoint; + // nothing is substituted. + GRID_COLS[baseCols], // Responsive columns smCols && GRID_COLS_SM[smCols], mdCols && GRID_COLS_MD[mdCols], @@ -179,31 +205,52 @@ 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 + // 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. inputs: [ - { - name: 'columns', - type: 'number', - description: 'Number of columns on mobile devices' + { + name: 'columns', + type: ['enum', 'object'], + enum: COLUMN_COUNT_OPTIONS, + of: 'enum', + 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: 'number', - description: 'Columns at sm breakpoint (>640px)' + { + name: 'smColumns', + type: 'enum', + enum: COLUMN_COUNT_OPTIONS, + description: 'Column count, 1 to 12, at the sm breakpoint (>640px)' }, - { - name: 'mdColumns', - type: 'number', - description: 'Columns at md breakpoint (>768px)' + { + name: 'mdColumns', + type: 'enum', + enum: COLUMN_COUNT_OPTIONS, + description: 'Column count, 1 to 12, at the md breakpoint (>768px)' }, - { - name: 'lgColumns', - type: 'number', - description: 'Columns at lg breakpoint (>1024px)' + { + name: 'lgColumns', + type: 'enum', + enum: COLUMN_COUNT_OPTIONS, + description: 'Column count, 1 to 12, at the lg breakpoint (>1024px)' }, - { - name: 'xlColumns', - type: 'number', - description: 'Columns at xl breakpoint (>1280px)' + { + name: 'xlColumns', + type: 'enum', + enum: COLUMN_COUNT_OPTIONS, + description: 'Column count, 1 to 12, at the xl breakpoint (>1280px)' }, { name: 'gap', diff --git a/packages/core/src/builder/__tests__/schema-builder.test.ts b/packages/core/src/builder/__tests__/schema-builder.test.ts index dcc1d920c1..f9c111fb4b 100644 --- a/packages/core/src/builder/__tests__/schema-builder.test.ts +++ b/packages/core/src/builder/__tests__/schema-builder.test.ts @@ -131,6 +131,16 @@ describe('SchemaBuilder', () => { expect(schema.type).toBe('grid'); expect(schema.id).toBe('my-grid'); }); + + it('takes a column count the grid renderer maps, and nothing else (objectui#11491)', () => { + const schema = grid().columns(12).build(); + expect(schema.columns).toBe(12); + expect(safeValidateSchema(schema).success).toBe(true); + expect(StrictAnyComponentSchema.safeParse(schema).success).toBe(true); + // @ts-expect-error — `grid` maps no 13th column; `tsc` refuses it at the builder. + const thirteen = grid().columns(13).build(); + expect(safeValidateSchema(thirteen).success).toBe(false); + }); }); describe('flex()', () => { diff --git a/packages/core/src/builder/schema-builder.ts b/packages/core/src/builder/schema-builder.ts index d6657fc871..5aa197a44f 100644 --- a/packages/core/src/builder/schema-builder.ts +++ b/packages/core/src/builder/schema-builder.ts @@ -332,9 +332,11 @@ export class GridBuilder extends SchemaBuilder { } /** - * Set number of columns + * Set number of columns — one of the counts the `grid` renderer maps + * (objectui#11491); the parameter is the declaration's own bare-count arm, + * so `tsc` refuses any other number. */ - columns(columns: number): this { + columns(columns: Extract, number>): this { this.schema.columns = columns; return this; } diff --git a/packages/types/src/__tests__/grid-columns-breakpoint-narrowing-8505.test.ts b/packages/types/src/__tests__/grid-columns-breakpoint-narrowing-8505.test.ts index 0d89d28380..b5097c6865 100644 --- a/packages/types/src/__tests__/grid-columns-breakpoint-narrowing-8505.test.ts +++ b/packages/types/src/__tests__/grid-columns-breakpoint-narrowing-8505.test.ts @@ -87,6 +87,14 @@ type Eq = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 ? true : false; +/** + * The column counts the renderer maps. This card's subject is the KEY + * vocabulary; since objectui#11491 the VALUES are closed too, at the bare number + * and at every breakpoint, so the shapes below carry the count set where they + * said `number` (`grid-columns-set-11491.test.ts` pins the values themselves). + */ +type ColumnCount = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + /** * One authored node per breakpoint, EVERY key positively type-checked inside * `columns`. @@ -115,14 +123,14 @@ const BREAKPOINTS = Object.keys(ONE_KEY_NODES) as BreakpointName[]; /* ── (a) the member's type, pinned whole ──────────────────────────────────── */ describe('objectui#8505 — GridSchema.columns declares the breakpoint vocabulary', () => { - it('the member type is exactly the bare number OR a partial breakpoint map', () => { + it('the member type is exactly the bare count OR a partial breakpoint map of counts', () => { // Mutual assignability, so this fails in BOTH directions: widened back to // `Record` it fails, and narrowed to `Record` // or stripped of the `number` arm it also fails. A one-way `extends` check // would pass for the caricature. const _columnsShape: Eq< GridSchema['columns'], - number | Partial> | undefined + ColumnCount | Partial> | undefined > = true; expect(_columnsShape).toBe(true); }); @@ -131,7 +139,7 @@ describe('objectui#8505 — GridSchema.columns declares the breakpoint vocabular // This is the assertion that catches the `Record` caricature, // and the ONLY one in this file that does — see the table in the header. const _keyedByVocabulary: Eq< - keyof NonNullable>, + keyof NonNullable>, BreakpointName > = true; expect(_keyedByVocabulary).toBe(true); @@ -212,7 +220,7 @@ describe('objectui#8505 — a key outside the vocabulary no longer compiles', () /* ── (d) the boundary: what this narrowing does NOT catch, measured ───────── */ describe('objectui#8505 — the narrowing broke no producer, and this is why', () => { - it('a computed `Record` still assigns — index signatures satisfy optional members', () => { + it('a computed record of counts still assigns — index signatures satisfy optional members', () => { // The blast-radius question this card was dispatched with: does something // assign into `columns` from a COMPUTED record rather than a literal, and // would that go red at the call site? Measured two ways and they agree. @@ -227,17 +235,30 @@ describe('objectui#8505 — the narrowing broke no producer, and this is why', ( // Empirically: `turbo run type-check` over the whole repo — 81 tasks, every // package's source, test and example programs — was green with the // narrowing applied and zero call sites edited. - const computed: Record = { xs: 1, sm: 2 }; + // + // ⚠️ Re-judged by objectui#11491, which closed the VALUES to the counts the + // renderer maps. The escape this row pins is the KEY one and it stands: the + // record below is computed, keyed by `string`, and still assigns. What no + // longer assigns is a `Record`, on purpose — its values may + // be any number, and `grid` maps 1 to 12 only — so the row's record is typed + // by the counts, and the refusal is pinned beside it. + const computed: Record = { xs: 1, sm: 2 }; const fromComputed: GridSchema = { type: 'grid', columns: computed }; expect(fromComputed.columns).toEqual({ xs: 1, sm: 2 }); + const anyNumber: Record = { xs: 1, sm: 2 }; + // @ts-expect-error — a `number` value may be a count `grid` does not map (objectui#11491) + const fromAnyNumber: GridSchema = { type: 'grid', columns: anyNumber }; + expect(fromAnyNumber).toBeDefined(); }); it('a NON-fresh object with at least one valid key also still assigns', () => { // Excess-property checking only fires on fresh literals. Through a variable // the extra key survives the type system — so the narrowing is a check on // the authoring spelling, not a proof that no bad key can reach the - // renderer. Stated rather than papered over. - const viaVariable = { xs: 1, xxl: 6 }; + // renderer. Stated rather than papered over. (`as const` since + // objectui#11491: a widened `number` value is refused for its value, which + // would hide the key fact this row is about.) + const viaVariable = { xs: 1, xxl: 6 } as const; const node: GridSchema = { type: 'grid', columns: viaVariable }; expect(node.columns).toEqual({ xs: 1, xxl: 6 }); }); diff --git a/packages/types/src/__tests__/grid-columns-set-11491.test.ts b/packages/types/src/__tests__/grid-columns-set-11491.test.ts new file mode 100644 index 0000000000..0734c84860 --- /dev/null +++ b/packages/types/src/__tests__/grid-columns-set-11491.test.ts @@ -0,0 +1,169 @@ +/** + * 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. + */ + +/** + * `grid.columns` is closed to the counts the `grid` renderer maps, 1 to 12, on + * both faces, as the bare number and at every breakpoint, and the refusal names + * the set (objectui#11491, the numeric-layout family's close-out after + * objectui#11424's `container.padding` and objectui#11474's `gap` keys). + * + * ## The defect + * + * The member was `number | Partial>` on the + * declaration and `z.number()` in both arms of the mirror. `grid.tsx` spells + * `grid-cols-1` to `grid-cols-12` once per breakpoint and nothing else, so a + * count outside that set parsed green on the tolerant face + * (`safeValidateSchema`, what `objectui validate` runs) and on the strict + * authoring face, and drew no column class where it was authored: a bare `13` + * drew `grid-cols-1 sm:grid-cols-2` and lost its `md` count, `{ md: 13 }` drew + * nothing at `md`, and `{ xs: 13 }`, `0` and `-1` drew `grid-cols-2` through a + * fallback the renderer no longer has. + * + * ## The pins + * + * An unmapped count is refused at `columns` on both doors, bare and at each + * breakpoint. `columns` is a union (a count, or the breakpoint object), so the + * refusal is one `invalid_union` whose message names the set; the arm the + * value was meant for carries the set structurally, as an `invalid_value` + * whose `values` are the counts (at the breakpoint's own path, for the object). + * Each mapped count parses, bare and at each breakpoint; an absent key parses; + * an out-of-vocabulary breakpoint is still `unrecognized_keys` + * (objectui#8516). The set here is the declared one; that it IS the renderer's + * set is re-derived by rendering in + * `components/src/__tests__/layout-spacing-sets-11474.test.tsx`. + * + * `BaseSchema` is `.passthrough()`, so the refusal gets a lit control: an + * undeclared key beside a mapped count stays green on the node, which shows the + * refusal is the declared key's own verdict. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import { GridSchema } from '../zod/layout.zod.js'; +import { safeValidateSchema, StrictAnyComponentSchema } from '../zod/index.zod.js'; +import type { GridSchema as GridSchemaType } from '../layout.js'; + +interface Issue { + code: string; + path: PropertyKey[]; + message: string; + values?: unknown[]; + keys?: string[]; + errors?: Issue[][]; +} + +const issuesOf = (r: { success: boolean; error?: { issues: unknown[] } }): Issue[] => + r.success ? [] : (r.error!.issues as Issue[]); + +const UNKNOWN_KEY = 'zzzNotAKeyAnySurfaceDeclares11491'; + +const COUNTS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; +const SET = COUNTS.join(', '); +const BREAKPOINTS = ['xs', 'sm', 'md', 'lg', 'xl', '2xl'] as const; +const UNMAPPED = [0, 13, 16, -1, 2.5]; + +/** The doors an authored document passes through. */ +const FACES = { + tolerant: (doc: unknown) => safeValidateSchema(doc), + strict: (doc: unknown) => StrictAnyComponentSchema.safeParse(doc), +} as const; + +const columns = GridSchema.shape.columns.unwrap(); +const [countArm, objectArm] = columns.options; + +describe('`grid.columns` is closed to the renderer\'s counts (objectui#11491)', () => { + it('the mirror declares exactly the set, in both arms', () => { + expect([...countArm.values].sort((a, b) => a - b)).toEqual(COUNTS); + // The object's values are the same closed literal, not a second spelling of it. + expect(objectArm.valueType).toBe(countArm); + expect(objectArm.keyType.options).toEqual([...BREAKPOINTS]); + }); + + it('the describe states the set, not "Number of columns"', () => { + const description = GridSchema.shape.columns.description ?? ''; + expect(description).toContain(SET); + expect(description).toContain('default 2'); + }); + + for (const [face, parse] of Object.entries(FACES)) { + describe(`${face} face`, () => { + for (const value of UNMAPPED) { + it(`refuses the bare count ${value} at \`columns\`, naming the set`, () => { + const issues = issuesOf(parse({ type: 'grid', columns: value })); + expect(issues).toHaveLength(1); + const [issue] = issues; + expect(issue.code).toBe('invalid_union'); + expect(issue.path).toEqual(['columns']); + expect(issue.message).toContain(SET); + // The count arm carries the set itself. + const countIssue = issue.errors!.flat().find((i) => i.code === 'invalid_value'); + expect(countIssue?.path).toEqual([]); + expect(countIssue?.values).toEqual(COUNTS); + }); + } + + for (const bp of BREAKPOINTS) { + it(`refuses an unmapped count at \`${bp}\`, naming the set and the breakpoint`, () => { + const value = UNMAPPED[BREAKPOINTS.indexOf(bp) % UNMAPPED.length]; + const issues = issuesOf(parse({ type: 'grid', columns: { [bp]: value } })); + expect(issues).toHaveLength(1); + const [issue] = issues; + expect(issue.code).toBe('invalid_union'); + expect(issue.path).toEqual(['columns']); + expect(issue.message).toContain(SET); + // The object arm names the breakpoint and carries the set. + const memberIssue = issue.errors!.flat().find((i) => i.path.length === 1); + expect(memberIssue).toMatchObject({ code: 'invalid_value', path: [bp], values: COUNTS }); + }); + } + + it('accepts each mapped count, bare and at every breakpoint', () => { + for (const value of COUNTS) { + expect(parse({ type: 'grid', columns: value }).success, `columns ${value} refused`).toBe(true); + for (const bp of BREAKPOINTS) { + expect(parse({ type: 'grid', columns: { [bp]: value } }).success, `columns.${bp} ${value} refused`).toBe(true); + } + } + const full = Object.fromEntries(BREAKPOINTS.map((bp, i) => [bp, COUNTS[i * 2]])); + expect(parse({ type: 'grid', columns: full }).success).toBe(true); + }); + + it('control: the node without columns, and with an empty object, still parses', () => { + expect(parse({ type: 'grid' }).success).toBe(true); + expect(parse({ type: 'grid', columns: {} }).success).toBe(true); + }); + + it('control: a breakpoint outside the vocabulary is still reported by key (objectui#8516)', () => { + const issues = issuesOf(parse({ type: 'grid', columns: { xxl: 3 } })); + expect(issues).toEqual([expect.objectContaining({ code: 'unrecognized_keys', path: ['columns'], keys: ['xxl'] })]); + }); + }); + } + + it('lit control: an undeclared key beside a mapped count stays green on the mirror', () => { + expect(GridSchema.safeParse({ type: 'grid', columns: 12, [UNKNOWN_KEY]: true }).success).toBe(true); + }); + + it('the declaration takes the same set (compile-time: refused by `tsc` when it does not)', () => { + // @ts-expect-error — `grid` maps no 13th column. + const thirteen: GridSchemaType = { type: 'grid', columns: 13 }; + // @ts-expect-error — nor a zero-column grid. + const zero: GridSchemaType = { type: 'grid', columns: 0 }; + // @ts-expect-error — the breakpoint object takes the same counts. + const mdThirteen: GridSchemaType = { type: 'grid', columns: { md: 13 } }; + const twelve: GridSchemaType = { type: 'grid', columns: 12 }; + const ramp: GridSchemaType = { type: 'grid', columns: { xs: 1, md: 6, '2xl': 12 } }; + expect([thirteen, zero, mdThirteen, twelve, ramp]).toHaveLength(5); + }); + + it('the two faces state one set (compile-time)', () => { + type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + const same: Same['columns']>, NonNullable> = true; + expect(same).toBe(true); + }); +}); diff --git a/packages/types/src/__tests__/mirror-partial-record-narrowing-8516.test.ts b/packages/types/src/__tests__/mirror-partial-record-narrowing-8516.test.ts index b7ebd4e21e..025824f333 100644 --- a/packages/types/src/__tests__/mirror-partial-record-narrowing-8516.test.ts +++ b/packages/types/src/__tests__/mirror-partial-record-narrowing-8516.test.ts @@ -102,7 +102,12 @@ describe('objectui#8516 — the grid mirror states the declaration, at compile t // `z.record(z.enum(SIX), z.number())` infers the TOTAL `Record`, so this // constant is the compile-time half of the overshoot guard: it reddens on // the forbidden spelling without anyone having to run a parse. - const _columnsIsPartial: Eq>> = true; + // The values are the counts the renderer maps since objectui#11491; this + // row's subject is the map's partiality and its keys. + const _columnsIsPartial: Eq< + MirrorColumnMap, + Partial> + > = true; expect(_columnsIsPartial).toBe(true); }); }); diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts index 47b6f55b78..370c874414 100644 --- a/packages/types/src/layout.ts +++ b/packages/types/src/layout.ts @@ -828,14 +828,28 @@ export interface StackSchema extends BaseSchema, Omit { body?: never; } +/** + * The column counts the `grid` renderer maps to a column class + * (objectui#11491): {@link GridSchema.columns} takes one, as the bare number + * and at every breakpoint. Module-local: the member is the published face. + */ +type GridColumnCount = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + /** * CSS Grid layout component */ export interface GridSchema extends BaseSchema { type: 'grid'; /** - * Number of columns (responsive). - * Can be number or object: { xs: 1, sm: 2, md: 3, lg: 4 } + * Number of columns (responsive): a count from 1 to 12, or an object of such + * counts keyed by breakpoint: { xs: 1, sm: 2, md: 3, lg: 4 } + * + * The counts are the ones `grid.tsx` maps to a column class, and only those + * (objectui#11491): it spells `grid-cols-1` to `grid-cols-12` once per + * breakpoint and nothing else, so any other count drew no column class where + * it was authored. This was `number`, at the bare number and at every + * breakpoint; the zod mirror (`zod/layout.zod.ts`) refuses the rest with the + * set named. * * `grid.tsx` opens with `let baseCols = 2` and only overwrites it from an * authored `columns`, so a `grid` that omits the key renders `grid-cols-2`. @@ -844,7 +858,7 @@ export interface GridSchema extends BaseSchema { * `maxWidth`). * @default 2 */ - columns?: number | Partial>; + columns?: GridColumnCount | Partial>; /** * 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 45fdf87d2b..1e1988967c 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -299,52 +299,85 @@ export const SeparatorSchema = BaseSchema.extend({ }); /** - * A layout spacing key closed to the steps its renderer maps, with a refusal - * that names them (objectui#11424, generalised by objectui#11474). - * - * `container.padding`, `stack.gap`, `flex.gap` and `grid.gap` are not keys - * `@objectstack/spec` declares, so each read site is the truth (the - * objectui#7759 ruling, the one objectui#10286 applied to `container.maxWidth`). - * Each renderer reads `schema.KEY ?? DEFAULT` and maps a closed set of steps to - * utility classes. A number outside the set reaches no rule in the compiled + * A numeric layout key closed to the set its renderer maps, with a refusal that + * names it (objectui#11424; generalised by objectui#11474 to every spacing key, + * and by objectui#11491 to `grid.columns`, a count, under the name this helper + * now has — it was `rendererSpacingSteps`). + * + * `container.padding`, `stack.gap`, `flex.gap`, `grid.gap` and `grid.columns` + * are not keys `@objectstack/spec` declares, so each read site is the truth + * (the objectui#7759 ruling, the one objectui#10286 applied to + * `container.maxWidth`). Each renderer maps a closed set of numbers to utility + * classes. A spacing step outside its set reaches no rule in the compiled * stylesheet, so the node renders without that spacing at all — not even the - * default, which `??` supplies only for an absent key. `z.number()` accepted - * such numbers. ⛔ The renderers neither round nor clamp an unmapped number, - * and must not start: the declaration closes to the set instead. + * default, which `??` supplies only for an absent key. A column count outside + * `grid`'s set drew no column class where it was authored. `z.number()` + * accepted such numbers. ⛔ The renderers neither round, clamp nor substitute + * an unmapped number, and must not start: the declaration closes to the set + * instead. * * The literal union is the refusal's carrier: zod's `invalid_value` issue - * lists the accepted values, and the message spells the same list out. + * lists the accepted values, and the message spells the same list out. A key + * that also takes an object of the same set keyed by breakpoint + * (`grid.columns`) passes `shape`, which builds that union from the ONE closed + * literal and gives the union the same refusal, so the set is named whichever + * arm the authored value missed. * * `components/src/__tests__/layout-spacing-sets-11474.test.tsx` enumerates - * every registered layout renderer's numeric spacing input, re-derives each - * set by rendering the real node and reading which values draw a class the - * package's compiled stylesheet defines, and holds this declaration and the + * every registered layout renderer's numeric input, re-derives each set by + * rendering the real node and reading which values draw a class the package's + * compiled stylesheet defines, and holds this declaration and the * registration's closed `enum` to it, so the three cannot part silently. */ -function rendererSpacingSteps(spec: { +interface RendererMappedSetSpec { /** The node type, as authored. */ node: string; - /** The spacing key on that node. */ - key: 'gap' | 'padding'; - /** The steps the renderer maps, in ascending order. */ + /** The numeric key on that node. */ + key: keyof typeof MAPPED_SET_WORDING; + /** The values the renderer maps, in ascending order. */ steps: Steps; - /** The step the renderer applies when the key is absent. */ + /** The value the renderer applies when the key is absent. */ fallback: Steps[number]; /** The card that closed this key. */ card: string; /** What an unmapped number did, measured, ending with the refusal's reason. */ unmapped: string; -}) { +} + +/** How each closed key's set is spoken of, in its refusal and its describe. */ +const MAPPED_SET_WORDING = { + gap: { noun: 'Gap step', unit: 'step', maps: 'a gap class', zero: true, forms: '' }, + padding: { noun: 'Padding step', unit: 'step', maps: 'a padding class', zero: true, forms: '' }, + columns: { + noun: 'Column count', + unit: 'count', + maps: 'a column class, as the bare number and at every breakpoint of the object form', + zero: false, + forms: ', bare or per breakpoint', + }, +} as const; + +function rendererMappedSet( + spec: RendererMappedSetSpec, +): z.ZodOptional>; +function rendererMappedSet( + spec: RendererMappedSetSpec, + shape: (closed: z.ZodLiteral, refusal: string) => Shape, +): z.ZodOptional; +function rendererMappedSet( + spec: RendererMappedSetSpec, + shape?: (closed: z.ZodLiteral, refusal: string) => z.ZodType, +): z.ZodOptional { const set = spec.steps.join(', '); + const words = MAPPED_SET_WORDING[spec.key]; const refusal = - `\`${spec.key}\` on a \`${spec.node}\` is one of ${set} (${spec.card}): those are the steps the ` + - `renderer maps to a ${spec.key} class, and \`0\` means none. ${spec.unmapped} ` + - 'Pick the step you meant from that set.'; - const noun = spec.key === 'gap' ? 'Gap' : 'Padding'; - return z - .literal(spec.steps, { error: refusal }) + `\`${spec.key}\` on a \`${spec.node}\` is one of ${set} (${spec.card}): those are the ${words.unit}s the ` + + `renderer maps to ${words.maps}${words.zero ? ', and `0` means none' : ''}. ${spec.unmapped} ` + + `Pick the ${words.unit} you meant from that set.`; + const closed = z.literal(spec.steps, { error: refusal }); + return (shape ? shape(closed, refusal) : closed) .optional() - .describe(`${noun} step, one of ${set}; 0 is none (default ${spec.fallback})`); + .describe(`${words.noun}, one of ${set}${words.zero ? '; 0 is none' : ''}${words.forms} (default ${spec.fallback})`); } /** @@ -372,8 +405,8 @@ export const ContainerSchema = BaseSchema.extend({ ]).optional().describe('Max width constraint'), centered: z.boolean().optional().describe('Center the container'), // A literal union of the renderer's mapped steps, not `z.number()` - // (objectui#11424) — see {@link rendererSpacingSteps}. - padding: rendererSpacingSteps({ + // (objectui#11424) — see {@link rendererMappedSet}. + padding: rendererMappedSet({ node: 'container', key: 'padding', steps: CONTAINER_PADDING_STEPS, @@ -434,7 +467,7 @@ export const FlexSchema = BaseSchema.extend({ // A literal union of the renderer's mapped steps, not `z.number()` // (objectui#11474). The authored bag holds this member BY REFERENCE // (`FlexPropsBag` below), so `properties.gap` refuses the same numbers. - gap: rendererSpacingSteps({ + gap: rendererMappedSet({ node: 'flex', key: 'gap', steps: FLEX_GAP_STEPS, @@ -617,7 +650,7 @@ export const StackSchema = BaseSchema.extend({ align: z.enum(['start', 'end', 'center', 'baseline', 'stretch']).optional(), // A literal union of the renderer's mapped steps, not `z.number()` // (objectui#11474) — see {@link STACK_GAP_STEPS}. - gap: rendererSpacingSteps({ + gap: rendererMappedSet({ node: 'stack', key: 'gap', steps: STACK_GAP_STEPS, @@ -646,20 +679,39 @@ export const StackSchema = BaseSchema.extend({ * (`gap-[2.25rem]` for `9`). Tailwind compiles only the class names it finds * in scanned source text, and a class assembled from a template at runtime is * not one of them, so an unmapped number reached no gap rule. The pin named in - * {@link rendererSpacingSteps} re-derives this against the package's own + * {@link rendererMappedSet} re-derives this against the package's own * compiled stylesheet on every run; the console's stylesheet was read the same * way once, on objectui#11474, and nothing re-derives that reading. The * describe's "Tailwind scale 0-8" was not this set either. */ const GRID_GAP_STEPS = [0, 1, 2, 3, 4, 5, 6, 8, 10, 12] as const; +/** + * The column counts the `grid` renderer maps to a column class + * (objectui#11491): `grid.tsx` spells `grid-cols-1` to `grid-cols-12` once per + * breakpoint, in `GRID_COLS` and its five breakpoint twins, and nothing else. + * Every one of those 72 classes is a rule in the package's compiled stylesheet; + * the pin named in {@link rendererMappedSet} re-derives that on every run. + * + * A count outside the set drew no column class where it was authored, and was + * still `z.number()` here. Measured through the real `SchemaRenderer`: a bare + * `13` drew `grid-cols-1 sm:grid-cols-2` and lost its `md` count; `{ md: 13 }` + * drew nothing at `md`; `{ xs: 13 }`, `0` and `-1` drew `grid-cols-2`, a count + * nobody authored, through a fallback the renderer no longer has. + */ +const GRID_COLUMN_COUNTS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const; + /** * Grid Schema - CSS Grid layout component */ export const GridSchema = BaseSchema.extend({ type: z.literal('grid'), /** - * Keyed by the BREAKPOINT VOCABULARY, not by `string` (objectui#8516). + * One of the counts the renderer maps (objectui#11491, see + * {@link GRID_COLUMN_COUNTS}): the bare number, or an object of such counts + * keyed by the BREAKPOINT VOCABULARY, not by `string` (objectui#8516). The + * one closed literal is both arms' value, so a count is judged the same way + * as the bare number and at every breakpoint. * * `z.partialRecord`, ⛔ never `z.record(z.enum([…]), …)`: measured on zod * 4.4.3, the plain `z.record` over an enum key REQUIRES every member, so @@ -672,14 +724,33 @@ export const GridSchema = BaseSchema.extend({ * derive from; the equality is held by a type-level pin in * `__tests__/mirror-partial-record-narrowing-8516.test.ts`, so adding or * dropping a breakpoint on either face fails to compile. + * + * The union carries the refusal as well as each arm: a count that misses + * both arms is reported as one `invalid_union` at `columns` whose message + * names the set, and the arms' own issues, under `errors`, carry it as + * `values`. A key outside the six is still reported on its own, as + * `unrecognized_keys` at `columns` (objectui#8516). */ - columns: z.union([ - z.number(), - z.partialRecord(z.enum(['xs', 'sm', 'md', 'lg', 'xl', '2xl']), z.number()), - ]).optional().describe('Number of columns (responsive)'), + columns: rendererMappedSet( + { + node: 'grid', + key: 'columns', + steps: GRID_COLUMN_COUNTS, + fallback: 2, + card: 'objectui#11491', + unmapped: + 'Any other count drew no column class where it was authored: a bare `13` lost its `md` ' + + 'count, `{ md: 13 }` drew nothing at `md`, and `{ xs: 13 }`, `0` and `-1` drew two ' + + 'columns nobody authored; so it is refused here instead.', + }, + (count, refusal) => + z.union([count, z.partialRecord(z.enum(['xs', 'sm', 'md', 'lg', 'xl', '2xl']), count)], { + error: (issue) => (issue.code === 'invalid_union' ? refusal : undefined), + }), + ), // A literal union of the renderer's mapped steps, not `z.number()` // (objectui#11474) — see {@link GRID_GAP_STEPS}. - gap: rendererSpacingSteps({ + gap: rendererMappedSet({ node: 'grid', key: 'gap', steps: GRID_GAP_STEPS, From 66c328cd8df7da0c7632bf42bcc45f4e24859455 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:09:01 +0000 Subject: [PATCH 2/2] test(types): a computed Record still assigns to grid columns; say so instead of pinning the opposite (objectui#11491) tsc -p tsconfig.test.json measured the 8505 boundary row: TypeScript does not judge an index signature's value type against optional target members, so the computed-record escape covers values as well as keys. The row keeps its original record, the changeset and the 8505 dated note state the measured reading. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .changeset/11491-grid-columns-set.md | 5 +++-- .../8505-grid-columns-breakpoint-narrowing.md | 2 +- ...-columns-breakpoint-narrowing-8505.test.ts | 21 ++++++++----------- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/.changeset/11491-grid-columns-set.md b/.changeset/11491-grid-columns-set.md index af4fa85f7a..09905531b2 100644 --- a/.changeset/11491-grid-columns-set.md +++ b/.changeset/11491-grid-columns-set.md @@ -17,8 +17,9 @@ and `{ "columns": { "xs": 13 } }`, `"columns": 0` and `"columns": -1` drew two c nobody authored. - `@object-ui/types`: `GridSchema.columns` is the literal union of 1 to 12, or a partial - breakpoint map of that union, on the TypeScript face, so `tsc` refuses any other count, and a - computed `Record` no longer assigns to it. The zod mirror refuses one at + breakpoint map of that union, on the TypeScript face, so `tsc` refuses any other count written + as a literal (a computed `Record` still assigns, as it did for keys since + objectui#8505; the mirror judges its counts). The zod mirror refuses one at `columns` as an `invalid_union` whose message lists the set; its arms carry the set as `values`, at the breakpoint's own path for the object form. `safeValidateSchema` (what `objectui validate` runs) and the strict authoring face both give that refusal. An unknown diff --git a/.changeset/8505-grid-columns-breakpoint-narrowing.md b/.changeset/8505-grid-columns-breakpoint-narrowing.md index a854875678..936d954cc7 100644 --- a/.changeset/8505-grid-columns-breakpoint-narrowing.md +++ b/.changeset/8505-grid-columns-breakpoint-narrowing.md @@ -53,4 +53,4 @@ an advisory sweep that never parses against the schema a file whose root carries (`children`, `className`, `body`, …), lists a file with none of those keys by name when the file does not validate, and exits non-zero on unreadable JSON only. -⚠️ **Dated note, 2026-10-02 — the column counts are closed too — objectui#11491.** At this change `GridSchema.columns` was `number | Partial>`, and a computed `Record` still assigned to it. Now each count, the bare number and every breakpoint's, is one of 1 to 12, the counts the `grid` renderer maps to a column class, on the TypeScript face and in the zod mirror. A computed record still assigns when its values are typed as those counts (the key escape described above stands); a `Record` no longer does. `.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 — the column counts are closed too — objectui#11491.** At this change `GridSchema.columns` was `number | Partial>`. Now each count, the bare number and every breakpoint's, is one of 1 to 12, the counts the `grid` renderer maps to a column class, on the TypeScript face and in the zod mirror, so a literal `{ md: 13 }` stops compiling as `{ xxl: 6 }` did. The escape described above still holds, for values as well as keys: a computed `Record` still assigns, and the zod mirror is what judges its counts. `.changeset/11491-grid-columns-set.md` states what ships. The rest of this entry is kept as the reading of this change. diff --git a/packages/types/src/__tests__/grid-columns-breakpoint-narrowing-8505.test.ts b/packages/types/src/__tests__/grid-columns-breakpoint-narrowing-8505.test.ts index b5097c6865..5e7d48ae6d 100644 --- a/packages/types/src/__tests__/grid-columns-breakpoint-narrowing-8505.test.ts +++ b/packages/types/src/__tests__/grid-columns-breakpoint-narrowing-8505.test.ts @@ -220,7 +220,7 @@ describe('objectui#8505 — a key outside the vocabulary no longer compiles', () /* ── (d) the boundary: what this narrowing does NOT catch, measured ───────── */ describe('objectui#8505 — the narrowing broke no producer, and this is why', () => { - it('a computed record of counts still assigns — index signatures satisfy optional members', () => { + it('a computed `Record` still assigns — index signatures satisfy optional members', () => { // The blast-radius question this card was dispatched with: does something // assign into `columns` from a COMPUTED record rather than a literal, and // would that go red at the call site? Measured two ways and they agree. @@ -236,19 +236,16 @@ describe('objectui#8505 — the narrowing broke no producer, and this is why', ( // package's source, test and example programs — was green with the // narrowing applied and zero call sites edited. // - // ⚠️ Re-judged by objectui#11491, which closed the VALUES to the counts the - // renderer maps. The escape this row pins is the KEY one and it stands: the - // record below is computed, keyed by `string`, and still assigns. What no - // longer assigns is a `Record`, on purpose — its values may - // be any number, and `grid` maps 1 to 12 only — so the row's record is typed - // by the counts, and the refusal is pinned beside it. - const computed: Record = { xs: 1, sm: 2 }; + // ⚠️ Re-read by objectui#11491, which closed the VALUES to the counts the + // renderer maps, and measured with `tsc -p tsconfig.test.json`: this row + // still compiles, `Record` and all. TypeScript does not + // judge an index signature's VALUE type against optional target members + // either, so the escape covers values as well as keys. The TypeScript face + // closes what an author writes as a literal; the zod mirror judges the + // values a computed record carries (`grid-columns-set-11491.test.ts`). + const computed: Record = { xs: 1, sm: 2 }; const fromComputed: GridSchema = { type: 'grid', columns: computed }; expect(fromComputed.columns).toEqual({ xs: 1, sm: 2 }); - const anyNumber: Record = { xs: 1, sm: 2 }; - // @ts-expect-error — a `number` value may be a count `grid` does not map (objectui#11491) - const fromAnyNumber: GridSchema = { type: 'grid', columns: anyNumber }; - expect(fromAnyNumber).toBeDefined(); }); it('a NON-fresh object with at least one valid key also still assigns', () => {