diff --git a/.changeset/11441-retire-nav-responsive-grid-layout.md b/.changeset/11441-retire-nav-responsive-grid-layout.md index 9e089c0912..e967d2b7a6 100644 --- a/.changeset/11441-retire-nav-responsive-grid-layout.md +++ b/.changeset/11441-retire-nav-responsive-grid-layout.md @@ -15,3 +15,5 @@ Migration, measured against `objectui validate` on both of its faces: - `basePath` has no app-document spelling: the strict face refuses it as an unrecognized key. It belongs to the shell, as `AppSchemaRenderer`'s `basePath` prop. **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. diff --git a/.changeset/11474-layout-spacing-sets.md b/.changeset/11474-layout-spacing-sets.md new file mode 100644 index 0000000000..df43c47001 --- /dev/null +++ b/.changeset/11474-layout-spacing-sets.md @@ -0,0 +1,42 @@ +--- +'@object-ui/types': minor +'@object-ui/components': minor +'@object-ui/core': minor +--- + +The `gap` of a `stack`, a `flex` and a `grid` node is one of the steps its renderer maps. +Any other number is refused at validation, with the set named (objectui#11474). + +| node | accepted `gap` steps | default | +|---|---|---| +| `stack` | 0, 1, 2, 3, 4, 5, 6, 8, 10 | 2 | +| `flex` | 0, 1, 2, 3, 4, 5, 6, 7, 8 | 2 | +| `grid` | 0, 1, 2, 3, 4, 5, 6, 8, 10, 12 | 4 | + +**Breaking for a `stack`, `flex` or `grid` that carries any other `gap` number.** The key +was declared as any number, and the `flex` and `grid` descriptions advertised "Tailwind +scale 0-8". But each renderer has one gap class per step and nothing for the rest: +`{ "type": "stack", "gap": 7 }` and `{ "type": "flex", "properties": { "gap": 9 } }` parsed +clean and rendered with no gap class at all, not even the default, because the default +applies only when the key is absent. A `grid` built a gap class at runtime for such a +number, and no compiled stylesheet defines a class built that way, so it rendered with no +gap either. + +- `@object-ui/types`: `StackSchema.gap`, `FlexLayoutProps.gap` (which `FlexSchema` and the + authored `flex` bag share) and `GridSchema.gap` are literal unions of the steps above on + the TypeScript face, so `tsc` refuses any other number. The zod mirrors refuse one at the + key (`invalid_value`, with the steps in the issue), with a message that lists the set. For + `flex` that is `properties.gap`, and the flat spelling stays refused by name. + `safeValidateSchema` (what `objectui validate` runs) and the strict authoring face both + give that refusal. A `gap` that is not a number at all is now reported as `invalid_value` + rather than `invalid_type`. +- `@object-ui/components`: the `gap` input of the `stack`, `flex` and `grid` registrations + changes from `type: 'number'` to a closed `enum` of the same steps, in the object form the + `container` registration's `padding` already uses. In the SDUI manifest, `validateTree` + now answers an unlisted number with `invalid-enum`. The renderers are unchanged: they do + not round or clamp, and an absent key still renders the default step. +- `@object-ui/core`: `GridBuilder.gap()` and `FlexBuilder.gap()` take the declared steps + instead of any number. + +Migration: replace the number with the step you meant from that node's set. `0` means no +gap. diff --git a/.changeset/6151-stack-schema-omit-collapse.md b/.changeset/6151-stack-schema-omit-collapse.md index 92f2ab2c8a..da6dfd7e59 100644 --- a/.changeset/6151-stack-schema-omit-collapse.md +++ b/.changeset/6151-stack-schema-omit-collapse.md @@ -50,3 +50,17 @@ package's own tsconfig and asserts (1) `StackSchema` declares exactly what `Flex declares, and (2) no member of the `LayoutSchema` union has lost any of `BaseSchema`'s named members — so the next heritage clause that collapses under the index signature reds for the whole class, not just for this one interface. + +⚠️ **Dated note, 2026-10-02 — `StackSchema` declares its own `gap` — objectui#11474.** +At this change `gap` was a `number` member of `FlexLayoutProps`, declared once and inherited by +`FlexSchema` and `StackSchema` alike, and `stack.tsx` was read as feeding it to a Tailwind +numeric scale; now each node's `gap` is the closed set of steps its renderer maps. +`FlexLayoutProps.gap` is `0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8`, shared by `FlexSchema` and the +authored `flex` bag. `StackSchema` extends `BaseSchema` and `Omit` and +declares its own `gap?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10`, the nine steps `stack.tsx` has a +gap class for. So `gap` is declared twice while the other members stay declared once, and `tsc` +refuses any other number on a `stack`, as it refuses `gap: 'large'`. That `Omit` crosses no +index signature (`FlexLayoutProps` carries none), so it erases no member name, and +`stack-schema-emitted-members.test.ts`, which measures the emitted declaration, still passes. +`.changeset/11474-layout-spacing-sets.md` states what ships. The rest of this entry is kept as +the reading of this change. diff --git a/content/docs/components/layout/flex.mdx b/content/docs/components/layout/flex.mdx index f7ef74f1c4..ab9feb60c5 100644 --- a/content/docs/components/layout/flex.mdx +++ b/content/docs/components/layout/flex.mdx @@ -40,7 +40,7 @@ interface FlexNode { className?: string; properties?: { direction?: 'row' | 'col' | 'row-reverse' | 'col-reverse'; - gap?: number; + gap?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; // default: 2 align?: 'start' | 'end' | 'center' | 'baseline' | 'stretch'; justify?: 'start' | 'end' | 'center' | 'between' | 'around' | 'evenly'; wrap?: boolean; @@ -52,3 +52,8 @@ interface FlexNode { Nothing changes at render time: `SchemaRenderer` hoists every `properties` key onto the node before the `flex` renderer reads it, so a stored node that still writes these props flat keeps rendering. + +`gap` is a step on the flex spacing scale, 0 to 8, and `0` means none. Those are the steps the +renderer maps to a gap class, so they are the only values validation accepts: +`"properties": { "gap": 9 }` is refused with the set named (objectui#11474). Such a number +used to pass validation and then render with no gap at all, not even the default. diff --git a/content/docs/components/layout/grid.mdx b/content/docs/components/layout/grid.mdx index 9302e92d12..1b1514d136 100644 --- a/content/docs/components/layout/grid.mdx +++ b/content/docs/components/layout/grid.mdx @@ -15,8 +15,14 @@ import type { SchemaNode } from '@object-ui/types'; interface GridSchema { type: 'grid'; columns?: number; - gap?: number; + gap?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12; // default: 4 children: SchemaNode[]; className?: string; } ``` + +`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 +used to build a gap class at runtime that no compiled stylesheet defines, so the grid rendered +with no gap at all. diff --git a/content/docs/components/layout/stack.mdx b/content/docs/components/layout/stack.mdx index d4cb93468d..c96400d878 100644 --- a/content/docs/components/layout/stack.mdx +++ b/content/docs/components/layout/stack.mdx @@ -14,8 +14,14 @@ import type { SchemaNode } from '@object-ui/types'; interface StackSchema { type: 'stack'; - gap?: number; + gap?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10; // default: 2 children: SchemaNode[]; className?: string; } ``` + +`gap` is a step on the stack's spacing scale, and `0` means none. The nine steps are the +ones the renderer maps to a gap class, so they are the only values validation accepts: +`"gap": 7` or `"gap": 9` is refused with the set named (objectui#11474). Such a number used +to pass validation and then render with no gap at all, not even the default. A `flex` maps a +different set: it has `7` and no `10`. diff --git a/packages/components/src/__tests__/container-padding-set-11424.test.tsx b/packages/components/src/__tests__/container-padding-set-11424.test.tsx deleted file mode 100644 index d57eca7bb4..0000000000 --- a/packages/components/src/__tests__/container-padding-set-11424.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/** - * 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. - */ - -/** - * The `container` padding set is ONE set in three places (objectui#11424): - * the steps the renderer's branches map to a padding class, the literal set - * `ContainerSchema.padding` declares, and the closed `enum` the registration's - * `padding` input publishes into the manifest. - * - * The renderer is the truth (the objectui#7759 ruling: `padding` is not a spec - * key, so the read site decides). This file therefore DERIVES the mapped set by - * rendering the real `container` through `SchemaRenderer` for every candidate - * number and reading which ones put a padding utility on the element, then - * compares the other two lists with it. A branch added or removed in - * `container.tsx` without the declaration and the registration following — - * or the reverse — reddens here. - * - * The derivation also reads the defect itself: an unmapped number (9, 20) - * draws no padding class at all. That is the renderer's behaviour on purpose — - * ⛔ it does not round or clamp; the declaration refuses those numbers instead. - * - * 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 { ContainerSchema as ContainerMirror } from '@object-ui/types/zod'; - -/** Every candidate the derivation renders: a range past the widest step, plus fractions and a negative. */ -const CANDIDATES = [...Array.from({ length: 33 }, (_, i) => i), -1, 0.5, 1.5, 9.5]; - -/** `maxWidth: false` and `centered: false` keep the base class list free of anything but the padding ladder. */ -function classOf(schema: Record): string[] { - const { container } = render( - , - ); - return (container.firstElementChild as HTMLElement).className.split(/\s+/).filter(Boolean); -} - -/** A padding utility at any breakpoint: `p-2`, `sm:p-3`, `md:p-0.5`. */ -const isPaddingClass = (token: string) => /^(?:[a-z0-9]+:)?p-/.test(token); - -const sortNumbers = (values: Iterable) => [...values].map(Number).sort((a, b) => a - b); - -/** - * The rendered set, derived once on first use — inside a test, so RTL's - * per-test cleanup unmounts what it rendered (the class lists are read first). - */ -let renderedCache: number[] | undefined; -const renderedSet = (): number[] => - (renderedCache ??= CANDIDATES.filter((n) => classOf({ padding: n }).some(isPaddingClass))); - -describe('container padding: the rendered set, the declared set and the registered set are one (objectui#11424)', () => { - it('the derivation is not vacuous: it finds mapped steps AND unmapped numbers', () => { - const rendered = renderedSet(); - expect(rendered.length).toBeGreaterThan(0); - expect(rendered.length).toBeLessThan(CANDIDATES.length); - }); - - it('an unmapped number draws no padding class — 9 and 20 are neither rounded nor clamped', () => { - expect(classOf({ padding: 9 }).filter(isPaddingClass)).toEqual([]); - expect(classOf({ padding: 20 }).filter(isPaddingClass)).toEqual([]); - }); - - it('`ContainerSchema.padding` declares exactly the rendered set', () => { - const declared = ContainerMirror.shape.padding.unwrap().values; - expect(sortNumbers(declared)).toEqual(sortNumbers(renderedSet())); - }); - - it('the registration publishes exactly the rendered set, as a closed enum', () => { - const input = ComponentRegistry.getConfig('container')?.inputs?.find((i) => i.name === 'padding'); - expect(input?.type).toBe('enum'); - const published = (input?.enum ?? []).map((e) => (typeof e === 'object' ? e.value : e)); - expect(sortNumbers(published)).toEqual(sortNumbers(renderedSet())); - }); - - it('control: an absent key still draws the default ladder (padding 4)', () => { - expect(classOf({}).filter(isPaddingClass)).toEqual(['p-2', 'sm:p-3', 'md:p-4']); - expect(classOf({}).filter(isPaddingClass)).toEqual(classOf({ padding: 4 }).filter(isPaddingClass)); - }); -}); diff --git a/packages/components/src/__tests__/layout-spacing-sets-11474.test.tsx b/packages/components/src/__tests__/layout-spacing-sets-11474.test.tsx new file mode 100644 index 0000000000..a5a7200698 --- /dev/null +++ b/packages/components/src/__tests__/layout-spacing-sets-11474.test.tsx @@ -0,0 +1,259 @@ +/** + * 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. + */ + +/** + * 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. + * + * ## 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. + * + * ## What "mapped" means — the compiled stylesheet decides + * + * The renderer is the truth (the objectui#7759 ruling: none of these keys is a + * spec key, so the read site decides), and what it draws only counts when a + * rule exists for it. Tailwind compiles the class names it finds in scanned + * 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. + * + * ⛔ An unmapped number draws no spacing at all — the renderers neither round + * nor clamp it, on purpose; the declaration refuses it instead. + * + * ## What each spacing key 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. + * + * Module-scope import of the renderers, not `beforeAll` (AGENTS.md §测试纪律). + */ +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import postcss from 'postcss'; +import tailwindPostcss from '@tailwindcss/postcss'; +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 { safeValidateSchema, StrictAnyComponentSchema } from '@object-ui/types/zod'; + +/** Every candidate the derivation renders: a range past the widest step, 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); + +const sortNumbers = (values: Iterable) => [...values].map(Number).sort((a, b) => a - b); + +/* ── The compiled stylesheet ─────────────────────────────────────────────── */ + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const entry = resolve(packageRoot, 'src/index.css'); + +/** CSS identifier escapes undone: `\32 xl\:gap-1\.5` -> `2xl:gap-1.5`. */ +const unescapeIdent = (escaped: string) => + escaped + .replace(/\\([0-9a-fA-F]{1,6}) ?/g, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16))) + .replace(/\\(.)/g, '$1'); + +/** The class names the compiled sheet has a rule for, read off each rule's leading class selector. */ +async function compileDefinedClasses(): Promise> { + const source = await readFile(entry, 'utf8'); + const result = await postcss([tailwindPostcss({ base: packageRoot })]).process(source, { from: entry }); + const defined = new Set(); + postcss.parse(result.css, { from: entry }).walkRules((rule) => { + for (const selector of rule.selectors) { + const m = /^\.((?:\\[0-9a-fA-F]{1,6} ?|\\.|[\w-])+)/.exec(selector.trim()); + if (m) defined.add(unescapeIdent(m[1])); + } + }); + return defined; +} + +let definedCache: Promise> | undefined; +const definedClasses = () => (definedCache ??= compileDefinedClasses()); + +/* ── The enumeration ─────────────────────────────────────────────────────── */ + +interface LayoutInput { + /** The authored type: the registry key without its namespace. */ + type: string; + key: string; + input: { type?: unknown; enum?: 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. */ +const NUMERIC_LAYOUT_INPUTS: LayoutInput[] = (() => { + const seen = new Set(); + const out: LayoutInput[] = []; + for (const config of ComponentRegistry.getAllConfigs()) { + if (seen.has(config.type)) continue; + seen.add(config.type); + if (!config.isContainer) continue; + 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 values = enumValues(input as LayoutInput['input']); + const numeric = + input.type === 'number' || (input.type === 'enum' && values.length > 0 && values.every((v) => typeof v === 'number')); + if (!numeric) continue; + out.push({ + type, + key: input.name, + input: input as LayoutInput['input'], + defaultValue: (config.defaultProps as Record | undefined)?.[input.name], + }); + } + } + return out.sort((a, b) => `${a.type}.${a.key}`.localeCompare(`${b.type}.${b.key}`)); +})(); + +/* ── Rendering ───────────────────────────────────────────────────────────── */ + +function spacingOf(type: string, extra: Record): string[] { + const { container, unmount } = render(); + const tokens = ((container.firstElementChild as HTMLElement | null)?.className ?? '') + .split(/\s+/) + .filter(isSpacingUtility); + unmount(); + return tokens; +} + +interface Derivation { + /** The spacing utilities 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. */ + mapped: number[]; + /** Spacing utilities every candidate draws (`mx-auto` on a centred container) — not the key's. */ + constant: string[]; +} + +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 }; +} + +/* ── The authoring doors ─────────────────────────────────────────────────── */ + +const FACES = { + tolerant: (doc: unknown) => safeValidateSchema(doc).success, + strict: (doc: unknown) => StrictAnyComponentSchema.safeParse(doc).success, +} 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. + */ +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); + return bag; +} + +/* ── The pins ────────────────────────────────────────────────────────────── */ + +describe('layout spacing keys: the rendered set, the declared set and the registered set are one (objectui#11474)', () => { + 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); + // … 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 () => { + 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}`); + } + // 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); + + for (const entry of NUMERIC_LAYOUT_INPUTS) { + const name = `${entry.type}.${entry.key}`; + + 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. + expect(mapped).toEqual([]); + return; + } + + // The derivation reads the defect itself: mapped steps 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. + 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([]); + } + + // 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)); + } + + // The registration, as a closed enum. + expect(entry.input.type, `${name} registration input`).toBe('enum'); + expect(sortNumbers(enumValues(entry.input)), `${name} registration enum`).toEqual(sortNumbers(mapped)); + + // 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 })); + }, 60_000); + } +}); diff --git a/packages/components/src/renderers/layout/container.tsx b/packages/components/src/renderers/layout/container.tsx index 01c7bf5371..ae8085d40b 100644 --- a/packages/components/src/renderers/layout/container.tsx +++ b/packages/components/src/renderers/layout/container.tsx @@ -158,7 +158,7 @@ ComponentRegistry.register('container', // refuses the rest on both faces; this list carries the same set into // the manifest, so `validateTree` answers `padding: 9` with // `invalid-enum` and the generated intrinsics type the prop as the - // twelve literals. `container-padding-set-11424.test.tsx` holds this + // twelve literals. `layout-spacing-sets-11474.test.tsx` holds this // list, the declaration and the rendered branches to one set. type: 'enum', enum: [ diff --git a/packages/components/src/renderers/layout/flex.tsx b/packages/components/src/renderers/layout/flex.tsx index 36c216d20c..1304a1955c 100644 --- a/packages/components/src/renderers/layout/flex.tsx +++ b/packages/components/src/renderers/layout/flex.tsx @@ -117,12 +117,29 @@ ComponentRegistry.register('flex', name: 'align', type: 'enum', enum: ['start', 'end', 'center', 'baseline', 'stretch'] }, - { - name: 'gap', - type: 'number', - - - description: 'Gap between items (0-8)' + { + name: 'gap', + // A closed list, in `container.padding`'s object form, not + // `type: 'number'` (objectui#11474): the `gap === N` branches above map + // exactly these steps, and any other number drew no gap class at all. + // `FlexSchema` and the authored `properties` bag refuse the rest on both + // faces; this list carries the same set into the SDUI manifest, so + // `validateTree` answers `gap: 9` with `invalid-enum`. + // `layout-spacing-sets-11474.test.tsx` holds this list, the declaration + // and the rendered branches to one set. + type: 'enum', + enum: [ + { label: '0 (none)', value: 0 }, + { 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 }, + ], + description: 'Gap step between items; 0 is none. Default 2.' }, { name: 'wrap', diff --git a/packages/components/src/renderers/layout/grid.tsx b/packages/components/src/renderers/layout/grid.tsx index 144d9c1c86..b8344a7b45 100644 --- a/packages/components/src/renderers/layout/grid.tsx +++ b/packages/components/src/renderers/layout/grid.tsx @@ -207,8 +207,29 @@ ComponentRegistry.register('grid', }, { name: 'gap', - type: 'number', - description: 'Gap between items (0-12)' + // A closed list, in `container.padding`'s object form, not + // `type: 'number'` (objectui#11474): these are the `GAPS` entries above. + // For any other number the renderer builds a class at runtime, and + // Tailwind never compiles a class it did not find in scanned source, so + // that grid drew no gap at all. `GridSchema` refuses the rest on both + // faces; this list carries the same set into the SDUI manifest, so + // `validateTree` answers `gap: 9` with `invalid-enum`. + // `layout-spacing-sets-11474.test.tsx` holds this list, the declaration + // and the rendered classes the compiled stylesheet defines to one set. + type: 'enum', + enum: [ + { label: '0 (none)', value: 0 }, + { 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: '8', value: 8 }, + { label: '10', value: 10 }, + { label: '12', value: 12 }, + ], + description: 'Gap step between items; 0 is none. Default 4.' }, { name: 'className', type: 'string' }, { name: 'children', type: 'slot' } diff --git a/packages/components/src/renderers/layout/stack.tsx b/packages/components/src/renderers/layout/stack.tsx index 4689c4ec66..93937df36c 100644 --- a/packages/components/src/renderers/layout/stack.tsx +++ b/packages/components/src/renderers/layout/stack.tsx @@ -119,9 +119,29 @@ ComponentRegistry.register('stack', name: 'direction', type: 'enum', enum: ['col', 'row', 'col-reverse', 'row-reverse'] }, - { - name: 'gap', - type: 'number' }, + { + name: 'gap', + // A closed list, in `container.padding`'s object form, not + // `type: 'number'` (objectui#11474): the `gap === N` branches above map + // exactly these steps, and any other number drew no gap class at all. + // `StackSchema` refuses the rest on both faces; this list carries the + // same set into the SDUI manifest, so `validateTree` answers `gap: 7` + // with `invalid-enum`. `layout-spacing-sets-11474.test.tsx` holds this + // list, the declaration and the rendered branches to one set. + type: 'enum', + enum: [ + { label: '0 (none)', value: 0 }, + { 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: '8', value: 8 }, + { label: '10', value: 10 }, + ], + description: 'Gap step between items; 0 is none. Default 2.' + }, { name: 'align', type: 'enum', diff --git a/packages/core/src/builder/schema-builder.ts b/packages/core/src/builder/schema-builder.ts index 4492425cb1..d6657fc871 100644 --- a/packages/core/src/builder/schema-builder.ts +++ b/packages/core/src/builder/schema-builder.ts @@ -340,9 +340,10 @@ export class GridBuilder extends SchemaBuilder { } /** - * Set gap + * Set gap — one of the steps the `grid` renderer maps (objectui#11474); the + * parameter is the declaration's own set, so `tsc` refuses any other number. */ - gap(gap: number): this { + gap(gap: NonNullable): this { this.schema.gap = gap; return this; } @@ -409,9 +410,10 @@ export class FlexBuilder extends SchemaBuilder): this { this.schema.properties.gap = gap; return this; } diff --git a/packages/types/src/__tests__/container-padding-set-11424.test.ts b/packages/types/src/__tests__/container-padding-set-11424.test.ts index 561c382427..bc4fa06af4 100644 --- a/packages/types/src/__tests__/container-padding-set-11424.test.ts +++ b/packages/types/src/__tests__/container-padding-set-11424.test.ts @@ -25,7 +25,7 @@ * 9 and 20 are refused on both faces with the set named; each mapped value * parses; an absent key still parses (the control — the renderer half of it, * that the absent key draws the default ladder, is in - * `components/src/__tests__/container-padding-set-11424.test.tsx`, which also + * `components/src/__tests__/layout-spacing-sets-11474.test.tsx`, which also * re-derives this set from the rendered branches). * * `BaseSchema` is `.passthrough()`, so the refusal also gets a lit control: an diff --git a/packages/types/src/__tests__/flex-properties-bag-11276.test.ts b/packages/types/src/__tests__/flex-properties-bag-11276.test.ts index 8f373aabd0..9952424d15 100644 --- a/packages/types/src/__tests__/flex-properties-bag-11276.test.ts +++ b/packages/types/src/__tests__/flex-properties-bag-11276.test.ts @@ -284,8 +284,10 @@ describe('the bag is the flat mirror\'s own members (objectui#11276)', () => { for (const [, judge] of FACES) { expect(issuesOf(judge({ type: 'flex', properties: { direction: 'column' } })).map((i) => [i.code, i.path])) .toEqual([['invalid_value', 'properties.direction']]); + // `invalid_value`, not `invalid_type`, since objectui#11474 closed `gap` to a literal set of + // the renderer's steps: zod judges a literal union by value, so a string is an off-set value. expect(issuesOf(judge({ type: 'flex', properties: { gap: '4' } })).map((i) => [i.code, i.path])) - .toEqual([['invalid_type', 'properties.gap']]); + .toEqual([['invalid_value', 'properties.gap']]); } }); diff --git a/packages/types/src/__tests__/layout-gap-sets-11474.test.ts b/packages/types/src/__tests__/layout-gap-sets-11474.test.ts new file mode 100644 index 0000000000..f3fdeeec0b --- /dev/null +++ b/packages/types/src/__tests__/layout-gap-sets-11474.test.ts @@ -0,0 +1,183 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `stack.gap`, `flex.gap` and `grid.gap` are each closed to the steps their + * renderer maps, on both faces, and the refusal names the set (objectui#11474, + * the family close-out after objectui#11424's `container.padding`). + * + * ## The defect + * + * Each key was `z.number()` on the mirror and `number` on the declaration, and + * the `flex` / `grid` describes advertised "Tailwind scale 0-8". The renderers + * map closed sets: `stack.tsx` and `flex.tsx` test `schema.gap ?? 2` against + * one branch per step, so `{ type: 'stack', gap: 7 }` and + * `{ type: 'flex', properties: { gap: 9 } }` parsed green on the tolerant face + * (`safeValidateSchema`, what `objectui validate` runs) and on the strict + * authoring face, and drew no gap class at all. `grid.tsx` builds + * `gap-[N*0.25rem]` at runtime for a number outside its map, a class no + * compiled stylesheet defines. + * + * ## The pins + * + * An unmapped number is refused at the key on every door the node is authored + * through, with the set in the issue and in the message; each mapped step + * parses; an absent key still parses. `flex` is authored in its `properties` + * bag (objectui#11276), so the bag is the door its refusal is pinned at; the + * flat spelling is refused too, by name, as every flat `flex` prop is. The + * sets here are the declared ones; that they ARE the renderers' sets is + * re-derived by rendering in + * `components/src/__tests__/layout-spacing-sets-11474.test.tsx`. + * + * `BaseSchema` is `.passthrough()`, so each refusal gets a lit control: an + * undeclared key beside a mapped step 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 { FlexSchema, GridSchema, StackSchema } from '../zod/layout.zod.js'; +import { safeValidateSchema, StrictAnyComponentSchema } from '../zod/index.zod.js'; +import type { + FlexLayoutProps, + FlexSchema as FlexSchemaType, + GridSchema as GridSchemaType, + StackSchema as StackSchemaType, +} from '../layout.js'; +import type { FlexBlockNode } from '../authoring-nodes.js'; + +interface Issue { + code: string; + path: PropertyKey[]; + message: string; + values?: unknown[]; +} + +const issuesOf = (r: { success: boolean; error?: { issues: unknown[] } }): Issue[] => + r.success ? [] : (r.error!.issues as Issue[]); + +const UNKNOWN_KEY = 'zzzNotAKeyAnySurfaceDeclares11474'; + +/** The declared sets, the authored spelling of each key, and the off-set numbers pinned as refusals. */ +const KEYS = [ + { + node: 'stack', + mirror: StackSchema, + steps: [0, 1, 2, 3, 4, 5, 6, 8, 10], + unmapped: [7, 9, 11, 2.5, -1], + authored: (gap: unknown) => ({ type: 'stack', gap }), + absent: { type: 'stack' }, + path: ['gap'], + }, + { + node: 'flex', + mirror: FlexSchema, + steps: [0, 1, 2, 3, 4, 5, 6, 7, 8], + unmapped: [9, 10, 12, 2.5, -1], + authored: (gap: unknown) => ({ type: 'flex', properties: { gap } }), + absent: { type: 'flex', properties: {} }, + path: ['properties', 'gap'], + }, + { + node: 'grid', + mirror: GridSchema, + steps: [0, 1, 2, 3, 4, 5, 6, 8, 10, 12], + unmapped: [7, 9, 11, 16, 2.5, -1], + authored: (gap: unknown) => ({ type: 'grid', gap }), + absent: { type: 'grid' }, + path: ['gap'], + }, +] as const; + +/** The doors an authored document passes through. */ +const FACES = { + tolerant: (doc: unknown) => safeValidateSchema(doc), + strict: (doc: unknown) => StrictAnyComponentSchema.safeParse(doc), +} as const; + +describe('layout `gap` keys are closed to their renderers\' steps (objectui#11474)', () => { + for (const key of KEYS) { + describe(`${key.node}.gap`, () => { + it('the mirror declares exactly the set', () => { + expect([...key.mirror.shape.gap.unwrap().values].sort((a, b) => a - b)).toEqual([...key.steps]); + }); + + it('the describe states the set, not a range', () => { + const description = key.mirror.shape.gap.description ?? ''; + expect(description).toContain(key.steps.join(', ')); + expect(description).not.toContain('0-8'); + }); + + for (const [face, parse] of Object.entries(FACES)) { + describe(`${face} face`, () => { + for (const value of key.unmapped) { + it(`refuses gap ${value} at the key, naming the set`, () => { + const issues = issuesOf(parse(key.authored(value))); + expect(issues).toHaveLength(1); + const [issue] = issues; + expect(issue.code).toBe('invalid_value'); + expect(issue.path).toEqual([...key.path]); + // The set, structurally: the issue carries the accept list itself … + expect(issue.values).toEqual([...key.steps]); + // … and the author-facing text spells that same list out. + expect(issue.message).toContain(key.steps.join(', ')); + }); + } + + it('accepts each mapped step', () => { + for (const value of key.steps) { + expect(parse(key.authored(value)).success, `${key.node} gap ${value} refused`).toBe(true); + } + }); + + it('control: the node without gap still parses', () => { + expect(parse(key.absent).success).toBe(true); + }); + }); + } + + it('lit control: an undeclared key beside a mapped step stays green on the mirror', () => { + expect(key.mirror.safeParse({ type: key.node, gap: key.steps[0], [UNKNOWN_KEY]: true }).success).toBe(true); + }); + }); + } + + it('flex: the flat spelling of an unmapped gap is refused too, by name', () => { + for (const parse of Object.values(FACES)) { + const issues = issuesOf(parse({ type: 'flex', gap: 9 })); + expect(issues.length).toBeGreaterThan(0); + expect(issues.every((issue) => issue.path[0] === 'gap')).toBe(true); + expect(issues.map((issue) => issue.message).join('\n')).toContain('properties.gap'); + } + }); + + it('the declarations take the same sets (compile-time: refused by `tsc` when they do not)', () => { + // @ts-expect-error — `stack` has no branch for 7. + const stackSeven: StackSchemaType = { type: 'stack', gap: 7 }; + // @ts-expect-error — `flex` has no branch for 9 … + const flexNine: FlexLayoutProps = { gap: 9 }; + // @ts-expect-error — … nor for 10, which `stack` maps. + const flexTen: FlexSchemaType = { type: 'flex', gap: 10 }; + // @ts-expect-error — the authored bag is the same member. + const bagNine: FlexBlockNode = { type: 'flex', properties: { gap: 9 } }; + // @ts-expect-error — `grid` has no entry for 9. + const gridNine: GridSchemaType = { type: 'grid', gap: 9 }; + const stackTen: StackSchemaType = { type: 'stack', gap: 10 }; + const flexSeven: FlexBlockNode = { type: 'flex', properties: { gap: 7 } }; + const gridTwelve: GridSchemaType = { type: 'grid', gap: 12 }; + expect([stackSeven, flexNine, flexTen, bagNine, gridNine, stackTen, flexSeven, gridTwelve]).toHaveLength(8); + }); + + it('each pair of faces states one set (compile-time)', () => { + type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + const stack: Same['gap']>, NonNullable> = true; + const flex: Same['gap']>, NonNullable> = true; + const grid: Same['gap']>, NonNullable> = true; + expect([stack, flex, grid]).toEqual([true, true, true]); + }); +}); diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts index 2d7a387c0c..47b6f55b78 100644 --- a/packages/types/src/layout.ts +++ b/packages/types/src/layout.ts @@ -691,10 +691,17 @@ export interface FlexLayoutProps { */ align?: 'start' | 'end' | 'center' | 'baseline' | 'stretch'; /** - * Gap between items (Tailwind scale 0-8) + * Gap step between items; `0` means none. + * + * The steps are the ones `flex.tsx` maps to a gap class, and only those + * (objectui#11474): it reads `schema.gap ?? 2` and tests it against one + * branch per step, so any other number matched no branch and drew no gap + * class at all — not even the default. This was `number`, which let `9` + * through; the zod mirror (`zod/layout.zod.ts`) refuses it with the set + * named. {@link StackSchema} maps a different set and declares its own. * @default 2 */ - gap?: number; + gap?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; /** * Allow items to wrap * @default false @@ -771,9 +778,29 @@ export interface FlexSchema extends BaseSchema, FlexLayoutProps { * for why they are shared through a third interface rather than derived with an * `Omit` (objectui#6151) — except `responsiveStyles`, which `FlexSchema` * declares on its own (objectui#10872 batch 9). + * + * `gap` is the one shared member `stack` declares itself: its renderer maps a + * different set of steps (objectui#11474). The `Omit` here crosses + * {@link FlexLayoutProps}, which carries no index signature, so it keeps every + * other member's name; the objectui#6151 hazard is an `Omit` over a type that + * inherits `BaseSchema`'s. `__tests__/stack-schema-emitted-members.test.ts` + * measures the emitted declaration and holds the two member sets equal. */ -export interface StackSchema extends BaseSchema, FlexLayoutProps { +export interface StackSchema extends BaseSchema, Omit { type: 'stack'; + /** + * Gap step between items; `0` means none. + * + * The steps are the ones `stack.tsx` maps to a gap class, and only those + * (objectui#11474): it reads `schema.gap ?? 2` and tests it against one + * branch per step, so any other number matched no branch and drew no gap + * class at all — not even the default. Unlike `flex`, `stack` has no branch + * for `7` and has one for `10`. This was `number`, which let `7` and `9` + * through; the zod mirror (`zod/layout.zod.ts`) refuses them with the set + * named. + * @default 2 + */ + gap?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10; /** * REFUSED BY NAME (objectui#8284, ADR-0049) — `stack` reads `children`, and no * renderer read consumes `body`. @@ -819,10 +846,17 @@ export interface GridSchema extends BaseSchema { */ columns?: number | Partial>; /** - * Gap between items (Tailwind scale 0-8) + * Gap step between items; `0` means none. + * + * The steps are the ones `grid.tsx` maps to a gap class, and only those + * (objectui#11474): it looks `schema.gap ?? 4` up in its `GAPS` map and, + * for any other number, builds an arbitrary-value class at runtime that no + * compiled stylesheet defines, so the grid drew no gap at all. This was + * `number`; the zod mirror (`zod/layout.zod.ts`) refuses the rest with the + * set named. * @default 4 */ - gap?: number; + gap?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12; /** * Child components */ diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index 62e8a89685..ab68f9ab1c 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -298,32 +298,63 @@ 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 + * 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. + * + * 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. + * + * `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 + * registration's closed `enum` to it, so the three cannot part silently. + */ +function rendererSpacingSteps(spec: { + /** The node type, as authored. */ + node: string; + /** The spacing key on that node. */ + key: 'gap' | 'padding'; + /** The steps the renderer maps, in ascending order. */ + steps: Steps; + /** The step 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; +}) { + const set = spec.steps.join(', '); + 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 }) + .optional() + .describe(`${noun} step, one of ${set}; 0 is none (default ${spec.fallback})`); +} + /** * The `padding` steps the `container` renderer maps to a padding class - * (objectui#11424) — the read site's set, not a design choice made here. - * - * `ContainerSchema.padding` is not a key `@objectstack/spec` declares, so the - * read site is the truth (the objectui#7759 ruling, the one objectui#10286 - * applied to `maxWidth` on this same node). `container.tsx` reads - * `schema.padding ?? 4` and then tests it against one `padding === N` branch - * per step; a number that equals none of them matches no branch and draws NO - * padding class at all — not even the default, which `??` supplies only for an - * absent key. So `z.number()` accepted `9` and `20` and the container rendered - * flush. The renderer neither rounds nor clamps an unmapped number, and must - * not start: the declaration closes to the set instead. - * - * `components/src/__tests__/container-padding-set-11424.test.tsx` re-derives - * the set by rendering the real `container` and compares it with this list and - * with the registration's `padding` enum, so the three cannot part silently. + * (objectui#11424): `container.tsx` tests `schema.padding ?? 4` against one + * `padding === N` branch per step, and a number that equals none of them + * matches no branch and draws NO padding class. */ const CONTAINER_PADDING_STEPS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 12, 16] as const; -const CONTAINER_PADDING_REFUSAL = - `\`padding\` on a \`container\` is one of ${CONTAINER_PADDING_STEPS.join(', ')} ` + - '(objectui#11424): those are the steps the renderer maps to a padding class, and `0` means ' + - 'none. Any other number drew NO padding class at all, not even the default `4`, so it is ' + - 'refused here rather than rendered flush. Pick the step you meant from that set.'; - /** * Container Schema - Generic container component */ @@ -341,11 +372,17 @@ 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 CONTAINER_PADDING_STEPS}. - padding: z - .literal(CONTAINER_PADDING_STEPS, { error: CONTAINER_PADDING_REFUSAL }) - .optional() - .describe(`Padding step, one of ${CONTAINER_PADDING_STEPS.join(', ')}; 0 is none (default 4)`), + // (objectui#11424) — see {@link rendererSpacingSteps}. + padding: rendererSpacingSteps({ + node: 'container', + key: 'padding', + steps: CONTAINER_PADDING_STEPS, + fallback: 4, + card: 'objectui#11424', + unmapped: + 'Any other number drew NO padding class at all, not even the default `4`, so it is ' + + 'refused here rather than rendered flush.', + }), children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional(), body: aliasKeyRefusal( 'body', @@ -357,6 +394,15 @@ export const ContainerSchema = BaseSchema.extend({ ), }); +/** + * The `gap` steps the `flex` renderer maps to a gap class (objectui#11474): + * `flex.tsx` tests `schema.gap ?? 2` against one `gap === N` branch per step, + * 0 to 8, and a number that equals none of them draws NO gap class. The + * describe used to advertise "Tailwind scale 0-8", which was this set, but the + * declaration under it was `z.number()`. + */ +const FLEX_GAP_STEPS = [0, 1, 2, 3, 4, 5, 6, 7, 8] as const; + /** * Flex Schema - Flexbox layout component * @@ -385,7 +431,19 @@ export const FlexSchema = BaseSchema.extend({ align: z.enum(['start', 'end', 'center', 'baseline', 'stretch']) .optional() .describe('Align items'), - gap: z.number().optional().describe('Gap between items (Tailwind scale 0-8)'), + // 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({ + node: 'flex', + key: 'gap', + steps: FLEX_GAP_STEPS, + fallback: 2, + card: 'objectui#11474', + unmapped: + 'Any other number drew NO gap class at all, not even the default `2`, so it is refused ' + + 'here rather than rendered with no gap.', + }), wrap: z.boolean().optional().describe('Allow items to wrap'), children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional(), body: aliasKeyRefusal( @@ -540,6 +598,15 @@ export const FlexBlockSchema = BaseSchema.extend({ ), }); +/** + * The `gap` steps the `stack` renderer maps to a gap class (objectui#11474): + * `stack.tsx` tests `schema.gap ?? 2` against one `gap === N` branch per step. + * It has no branch for `7`, which `flex` maps, and one for `10`, which `flex` + * does not, so the two sets differ; a number that equals none of them draws + * NO gap class. + */ +const STACK_GAP_STEPS = [0, 1, 2, 3, 4, 5, 6, 8, 10] as const; + /** * Stack Schema - Vertical flex layout (shortcut) */ @@ -548,7 +615,18 @@ export const StackSchema = BaseSchema.extend({ direction: z.enum(['row', 'col', 'row-reverse', 'col-reverse']).optional(), justify: z.enum(['start', 'end', 'center', 'between', 'around', 'evenly']).optional(), align: z.enum(['start', 'end', 'center', 'baseline', 'stretch']).optional(), - gap: z.number().optional(), + // A literal union of the renderer's mapped steps, not `z.number()` + // (objectui#11474) — see {@link STACK_GAP_STEPS}. + gap: rendererSpacingSteps({ + node: 'stack', + key: 'gap', + steps: STACK_GAP_STEPS, + fallback: 2, + card: 'objectui#11474', + unmapped: + 'Any other number drew NO gap class at all, not even the default `2`, so it is refused ' + + 'here rather than rendered with no gap.', + }), wrap: z.boolean().optional(), children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional(), body: aliasKeyRefusal( @@ -561,6 +639,20 @@ export const StackSchema = BaseSchema.extend({ ), }); +/** + * The `gap` steps the `grid` renderer maps to a gap class (objectui#11474): + * `grid.tsx` looks `schema.gap ?? 4` up in its `GAPS` map. For any other + * number it builds an arbitrary-value class at runtime, `gap-[N*0.25rem]` + * (`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 + * 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; + /** * Grid Schema - CSS Grid layout component */ @@ -585,7 +677,18 @@ export const GridSchema = BaseSchema.extend({ z.number(), z.partialRecord(z.enum(['xs', 'sm', 'md', 'lg', 'xl', '2xl']), z.number()), ]).optional().describe('Number of columns (responsive)'), - gap: z.number().optional().describe('Gap between items (Tailwind scale 0-8)'), + // A literal union of the renderer's mapped steps, not `z.number()` + // (objectui#11474) — see {@link GRID_GAP_STEPS}. + gap: rendererSpacingSteps({ + node: 'grid', + key: 'gap', + steps: GRID_GAP_STEPS, + fallback: 4, + card: 'objectui#11474', + unmapped: + 'Any other number built a class at runtime that no compiled stylesheet defines, so the ' + + 'grid rendered with no gap at all, not even the default `4`; it is refused here instead.', + }), children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional(), body: aliasKeyRefusal( 'body',