From 0e6237fe61920965e0648af9b4786180c6f7bf6e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 12:13:12 +0000 Subject: [PATCH 1/2] fix(types): lift the flex example's direction/gap out of the inert `props` envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `compositeExample` in `packages/types/examples/data-display-examples.json` authored its layout configuration as `props: { direction: 'col', gap: 4 }`. `SchemaRenderer` hoists `properties.*` onto the node and spreads `props` as React props instead, so a renderer declared `({ schema })` — the ordinary component-renderer shape, which `flex.tsx` has — never reads the envelope. The example rendered with the default `row` direction and the default gap while presenting itself as a column with `gap: 4`, and nothing complained: BaseSchema is `.passthrough()`, so every gate accepted the spelling. Both faces of `FlexSchema` declare the two keys at node level (`zod/layout.zod.ts` as an enum + a number, `layout.ts` via `FlexLayoutProps`), so the fix is the lift, not a rename of `props` to `properties`. The pin is structural rather than acceptance-shaped for the same passthrough reason: acceptance cannot separate "lifted" from "still under `props`, admitted unexamined". It reads the fixture from disk, parses the node through both `FlexSchema` and the published `LayoutSchema` union, and asserts the parsed values are the authored ones rather than the schema defaults, plus a walk over every `type`-carrying object in the fixture with a positive control so the zero is a reading. Deliberately untouched: the three counter-example sites in `skills/objectui/rules/protocol.md` and `skills/objectui/guides/schema-expressions.md` are marked-wrong teaching material, verified intact and left alone. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BAZFhALsQsGqxui8sNqM8s --- .changeset/6751-flex-props-envelope-lift.md | 15 ++ .../types/examples/data-display-examples.json | 6 +- .../flex-props-envelope-lift-6751.test.ts | 133 ++++++++++++++++++ 3 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 .changeset/6751-flex-props-envelope-lift.md create mode 100644 packages/types/src/__tests__/flex-props-envelope-lift-6751.test.ts diff --git a/.changeset/6751-flex-props-envelope-lift.md b/.changeset/6751-flex-props-envelope-lift.md new file mode 100644 index 000000000..360fc93dc --- /dev/null +++ b/.changeset/6751-flex-props-envelope-lift.md @@ -0,0 +1,15 @@ +--- +--- + +Fixes the `flex` node in `packages/types/examples/data-display-examples.json`, which authored +its layout configuration under a `props` envelope (`props: { direction: 'col', gap: 4 }`). +`SchemaRenderer` hoists `properties.*` onto the node and spreads `props` as React props +instead, so a renderer declared `({ schema })` — the ordinary component-renderer shape, which +`flex.tsx` has — never reads the envelope. The example therefore rendered with the default +`row` direction and the default gap while presenting itself as a column with `gap: 4`. The +two keys are lifted onto the node, where `FlexSchema` declares them; `props` is not renamed, +because renaming it to `properties` would be a second spelling for something the schema +already declares at node level. + +Declared as releasing nothing: `packages/types/examples/**` is not in that package's +published `files`, and the accompanying pin is a test. No published behaviour changes. diff --git a/packages/types/examples/data-display-examples.json b/packages/types/examples/data-display-examples.json index 73e3e63dd..c7a41cf13 100644 --- a/packages/types/examples/data-display-examples.json +++ b/packages/types/examples/data-display-examples.json @@ -246,10 +246,8 @@ "compositeExample": { "type": "flex", "id": "user-profile-card", - "props": { - "direction": "col", - "gap": 4 - }, + "direction": "col", + "gap": 4, "children": [ { "type": "avatar", diff --git a/packages/types/src/__tests__/flex-props-envelope-lift-6751.test.ts b/packages/types/src/__tests__/flex-props-envelope-lift-6751.test.ts new file mode 100644 index 000000000..110a39ac7 --- /dev/null +++ b/packages/types/src/__tests__/flex-props-envelope-lift-6751.test.ts @@ -0,0 +1,133 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#6751 — the `flex` node in `data-display-examples.json` declares its + * layout keys at NODE level, not inside a `props` envelope. + * + * ## What was wrong + * + * `compositeExample` authored + * + * { "type": "flex", "props": { "direction": "col", "gap": 4 } } + * + * `SchemaRenderer` hoists `properties.*` onto the node and spreads `props` as + * React props instead, so a renderer declared `({ schema })` — the ordinary + * component-renderer shape, which `flex.tsx` has — never sees the envelope. + * The example therefore rendered with the DEFAULT `row` direction and the + * DEFAULT gap while presenting itself as a column with `gap: 4`. The fix is to + * lift the two keys onto the node, which is where `FlexSchema` declares them; + * it is NOT to rename `props` to `properties`. + * + * ## Why the assertions here are structural rather than acceptance-shaped + * + * `BaseSchema` is `.passthrough()`, so the broken document parsed GREEN through + * every schema in this package and would keep doing so. Acceptance cannot tell + * "lifted" from "still under `props`, admitted unexamined". What separates the + * two is the parsed VALUE: + * + * fixture state FlexSchema.parse(node).direction / .gap / 'props' in node + * props envelope 'row' (default) / 2 (default) / true + * lifted onto the node 'col' / 4 / false + * + * ## The fence this file must not cross (objectui#6751 triage, twice) + * + * Three same-shaped occurrences elsewhere in the repo are DELIBERATE + * counter-examples — `skills/objectui/rules/protocol.md`'s `card`, and + * `skills/objectui/guides/schema-expressions.md`'s `card` and `text`, each + * marked wrong where it stands. The walk below is scoped to this one fixture + * for that reason: a repo-wide "no node carries `props`" assertion would make + * the teaching material fail. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { FlexSchema, LayoutSchema } from '../zod/layout.zod'; + +const ROOT = resolve(__dirname, '../../../..'); +const FIXTURE = 'packages/types/examples/data-display-examples.json'; + +function readFixture(): any { + return JSON.parse(readFileSync(resolve(ROOT, FIXTURE), 'utf8')); +} + +/** + * Every object in `doc` that carries a `type` AND a `props` key, reported as + * `type` + JSON path. The `element:*` namespace is excluded because those + * renderers read `props` by design (`readProps` merges both bags); every other + * `type` is a component-renderer type, which never sees the envelope. + */ +function envelopeSites(doc: unknown, path = '$'): string[] { + const hits: string[] = []; + if (Array.isArray(doc)) { + doc.forEach((v, i) => hits.push(...envelopeSites(v, `${path}[${i}]`))); + return hits; + } + if (doc === null || typeof doc !== 'object') return hits; + const node = doc as Record; + if (typeof node.type === 'string' && !node.type.startsWith('element:') + && Object.prototype.hasOwnProperty.call(node, 'props')) { + hits.push(`${path} (type=${node.type})`); + } + for (const [k, v] of Object.entries(node)) hits.push(...envelopeSites(v, `${path}.${k}`)); + return hits; +} + +describe('objectui#6751 — flex layout keys sit on the node, not under `props`', () => { + it('compositeExample parses through FlexSchema with direction/gap as authored', () => { + const node = readFixture().compositeExample; + expect(node.type).toBe('flex'); + + const parsed = FlexSchema.parse(node); + // Under the envelope these two read the SCHEMA DEFAULTS ('row' / 2), which + // is precisely the bug: the document says one thing and renders another. + expect(parsed.direction).toBe('col'); + expect(parsed.gap).toBe(4); + // `.passthrough()` carries unknown keys through, so a surviving `props` on + // the PARSED node is the direct reading that the envelope is still there. + expect(Object.prototype.hasOwnProperty.call(parsed, 'props')).toBe(false); + }); + + it('compositeExample parses the same way through the published LayoutSchema union', () => { + const parsed = LayoutSchema.parse(readFixture().compositeExample) as Record; + expect(parsed.type).toBe('flex'); + expect(parsed.direction).toBe('col'); + expect(parsed.gap).toBe(4); + expect(Object.prototype.hasOwnProperty.call(parsed, 'props')).toBe(false); + }); + + it('no component-renderer node anywhere in the fixture carries a `props` envelope', () => { + expect(envelopeSites(readFixture())).toEqual([]); + }); + + it('positive control — the walk above catches an envelope when one is present', () => { + // Without this, the zero on the previous assertion could come from a walker + // that never reports anything. + const doc = readFixture(); + doc.compositeExample.props = { direction: 'col', gap: 4 }; + expect(envelopeSites(doc)).toEqual(['$.compositeExample (type=flex)']); + }); + + it('negative control — `properties` and the `element:*` carve-out are not flagged', () => { + // `properties` is the bag SchemaRenderer DOES hoist, and `element:*` reads + // `props` by design; flagging either would make the zero above meaningless. + expect(envelopeSites({ type: 'card', properties: { title: 'Customer Summary' } })).toEqual([]); + expect(envelopeSites({ type: 'element:div', props: { className: 'p-4' } })).toEqual([]); + }); + + it('negative control — the lift left the rest of the node untouched', () => { + const node = readFixture().compositeExample; + expect(node.id).toBe('user-profile-card'); + expect(node.children.map((c: { type: string }) => c.type)).toEqual([ + 'avatar', 'statistic', 'badge', 'list', + ]); + expect(node.children[0]).toMatchObject({ + type: 'avatar', alt: 'User Avatar', fallback: 'JD', size: 'lg', + }); + }); +}); From 8c4d20678c08931452a9905dc8db4a288a9d8de1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 12:19:28 +0000 Subject: [PATCH 2/2] test(types): type the 6751 fixture reader as JSON instead of `any` `readFixture(): any` tripped `@typescript-eslint/no-explicit-any` and AGENTS.md commandment #6. The document really is arbitrary JSON, so it is read as a `{ [key: string]: unknown }` and reached through an `objectAt` helper that refuses a non-object loudly rather than letting a renamed fixture key read as `undefined` and quietly weaken every assertion below it. No assertion changed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BAZFhALsQsGqxui8sNqM8s --- .../flex-props-envelope-lift-6751.test.ts | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/types/src/__tests__/flex-props-envelope-lift-6751.test.ts b/packages/types/src/__tests__/flex-props-envelope-lift-6751.test.ts index 110a39ac7..a4d6a06f8 100644 --- a/packages/types/src/__tests__/flex-props-envelope-lift-6751.test.ts +++ b/packages/types/src/__tests__/flex-props-envelope-lift-6751.test.ts @@ -52,8 +52,20 @@ import { FlexSchema, LayoutSchema } from '../zod/layout.zod'; const ROOT = resolve(__dirname, '../../../..'); const FIXTURE = 'packages/types/examples/data-display-examples.json'; -function readFixture(): any { - return JSON.parse(readFileSync(resolve(ROOT, FIXTURE), 'utf8')); +/** The fixture is an arbitrary JSON document, so it is read as one. */ +type JsonObject = { [key: string]: unknown }; + +function readFixture(): JsonObject { + return JSON.parse(readFileSync(resolve(ROOT, FIXTURE), 'utf8')) as JsonObject; +} + +/** `doc[key]`, refused loudly rather than read as `undefined` if it is not an object. */ +function objectAt(doc: JsonObject, key: string): JsonObject { + const value = doc[key]; + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${FIXTURE}: expected an object at \`${key}\`, got ${JSON.stringify(value)}`); + } + return value as JsonObject; } /** @@ -80,7 +92,7 @@ function envelopeSites(doc: unknown, path = '$'): string[] { describe('objectui#6751 — flex layout keys sit on the node, not under `props`', () => { it('compositeExample parses through FlexSchema with direction/gap as authored', () => { - const node = readFixture().compositeExample; + const node = objectAt(readFixture(), 'compositeExample'); expect(node.type).toBe('flex'); const parsed = FlexSchema.parse(node); @@ -94,7 +106,7 @@ describe('objectui#6751 — flex layout keys sit on the node, not under `props`' }); it('compositeExample parses the same way through the published LayoutSchema union', () => { - const parsed = LayoutSchema.parse(readFixture().compositeExample) as Record; + const parsed = LayoutSchema.parse(objectAt(readFixture(), 'compositeExample')) as JsonObject; expect(parsed.type).toBe('flex'); expect(parsed.direction).toBe('col'); expect(parsed.gap).toBe(4); @@ -109,7 +121,7 @@ describe('objectui#6751 — flex layout keys sit on the node, not under `props`' // Without this, the zero on the previous assertion could come from a walker // that never reports anything. const doc = readFixture(); - doc.compositeExample.props = { direction: 'col', gap: 4 }; + objectAt(doc, 'compositeExample').props = { direction: 'col', gap: 4 }; expect(envelopeSites(doc)).toEqual(['$.compositeExample (type=flex)']); }); @@ -121,12 +133,11 @@ describe('objectui#6751 — flex layout keys sit on the node, not under `props`' }); it('negative control — the lift left the rest of the node untouched', () => { - const node = readFixture().compositeExample; + const node = objectAt(readFixture(), 'compositeExample'); expect(node.id).toBe('user-profile-card'); - expect(node.children.map((c: { type: string }) => c.type)).toEqual([ - 'avatar', 'statistic', 'badge', 'list', - ]); - expect(node.children[0]).toMatchObject({ + const children = node.children as JsonObject[]; + expect(children.map((c) => c.type)).toEqual(['avatar', 'statistic', 'badge', 'list']); + expect(children[0]).toMatchObject({ type: 'avatar', alt: 'User Avatar', fallback: 'JD', size: 'lg', }); });