From 1f3f33f7798a2c2a2ef5b4855a9fdd0f74f58c63 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 02:45:53 +0000 Subject: [PATCH] feat(types)!: ObjectFormSection drops the inert className/gridClassName members objectstack#13626 (maintainer ruling 2026-09-01: "retire the reads ... Declaring the keys was weighed and not adopted") removed every renderer read of the two keys off an authored form-view section, but `ObjectFormSection` in @object-ui/types still declared both with doc comments promising an effect nothing delivered. Remove the members so the authored-metadata type agrees with the spec's strict FormSectionSchema, which declares neither. - packages/types/src/objectql.ts: drop the two members + JSDoc; leave a deliberate-omission note naming the ruling and the two pins. - packages/types/src/__tests__/object-form-section-style-keys-undeclared.test.ts: compile-time pin (`@ts-expect-error` on annotated literals, a directive-free contrast row, and a `Record` census). - packages/plugin-form fixture sectionStyleKeysRetired-13626.test.tsx: keeps authoring both keys, now through a fixture type widened at its boundary (`ObjectFormSection & { className; gridClassName }`); header reworded. - ObjectForm.tsx comment and plugin-form README: the "still declares both keys" sentences were made false by the removal; reworded. - changeset: @object-ui/types minor, breaking semantics in the body. The five per-layout section config types in plugin-form keep their members (read uncast for programmatic mounts). No zod mirror exists for ObjectFormSection (ObjectFormSchema in zod/objectql.zod.ts declares no `sections`), so a `?: never` tombstone has no parse door and was not used. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01V3hPr7riucnMfhcHY86Msd --- ...ject-form-section-style-keys-undeclared.md | 34 ++++ packages/plugin-form/README.md | 20 ++- packages/plugin-form/src/ObjectForm.tsx | 12 +- .../sectionStyleKeysRetired-13626.test.tsx | 34 +++- ...form-section-style-keys-undeclared.test.ts | 152 ++++++++++++++++++ packages/types/src/objectql.ts | 29 ++-- 6 files changed, 251 insertions(+), 30 deletions(-) create mode 100644 .changeset/7200-object-form-section-style-keys-undeclared.md create mode 100644 packages/types/src/__tests__/object-form-section-style-keys-undeclared.test.ts diff --git a/.changeset/7200-object-form-section-style-keys-undeclared.md b/.changeset/7200-object-form-section-style-keys-undeclared.md new file mode 100644 index 0000000000..5f3603b124 --- /dev/null +++ b/.changeset/7200-object-form-section-style-keys-undeclared.md @@ -0,0 +1,34 @@ +--- +'@object-ui/types': minor +--- + +`ObjectFormSection` no longer declares `className` / `gridClassName` (objectui#7200 — +the declared-but-inert remainder of objectstack#13626). + +**Breaking, deliberately.** A TypeScript literal annotated `ObjectFormSection` (or an +`ObjectFormSchema.sections` entry) that carries `className` or `gridClassName` is now a +compile error at the authoring site. Before this change the two members were declared +with doc comments promising a wrapper / grid class, while — since objectstack#13626 +retired the seven renderer reads (`@object-ui/plugin-form` 2026-09-01) — nothing +delivered it: an author could write either key, have it type-check, and get nothing. + +The authored-metadata type now agrees with `@objectstack/spec`, whose `FormSectionSchema` +is a strict object declaring neither key, and with the ruling's rationale (maintainer +2026-09-01, verbatim): "retire the reads … Declaring the keys was weighed and not adopted: +it would formally invite free Tailwind strings into authored metadata, the exact class +the boundary exists to keep out." A `?: never` tombstone was not used: `ObjectFormSection` +has no zod mirror (`ObjectFormSchema` in `zod/objectql.zod.ts` does not declare +`sections`), so there is no parse door to refuse at, and a tombstone is still a +declaration in completion and in the published `.d.ts`. + +**Not changed.** The five per-layout section config types in `@object-ui/plugin-form` +(`ModalFormSectionConfig`, `SplitFormSectionConfig`, TabbedForm's `FormSectionConfig`, +`WizardStepConfig`, `DrawerFormSectionConfig`) keep their `className` / `gridClassName`: +their renderers read them for programmatic React mounts, which the authorable boundary +does not govern. The form ROOT `className` (`ObjectFormSchema.className`) is a different +key on a different node and is unaffected. Runtime behaviour is unchanged — JSON metadata +carrying either key was already ignored. + +**Migration.** Remove the two keys from any `ObjectFormSection` literal; they did nothing. +Style sections through the host application's own CSS or the form ROOT `className`. +Section *layout* stays authorable through `columns`. diff --git a/packages/plugin-form/README.md b/packages/plugin-form/README.md index ac3a8213a5..4360541f4b 100644 --- a/packages/plugin-form/README.md +++ b/packages/plugin-form/README.md @@ -378,9 +378,11 @@ columns empty — #2128). ### Section styling is not authorable -`className` and `gridClassName` on a form-view **section** do nothing. Authoring -them is not an error and not a compile failure — the renderer simply does not -read them. +`className` and `gridClassName` on a form-view **section** do nothing. In JSON +metadata, authoring them is not a parse error — the renderer simply does not +read them. In TypeScript they are a compile error: `ObjectFormSection` in +`@object-ui/types` does not declare either key (objectui#7200), matching the +spec's strict `FormSectionSchema`. This is deliberate. Both keys sit on the SDUI-only side of the authorable boundary: `@objectstack/spec` does not declare either on the form-view/section @@ -401,10 +403,14 @@ different key on a different node and is unaffected; section *layout* stays authorable through `columns` (above); and a host application styles sections through its own CSS. -> Note for contributors: `ObjectFormSection` in `@object-ui/types` still declares -> both keys, so a plain `className: s.className` would type-check and silently -> restore consumption. The non-consumption is therefore pinned behaviourally, in -> `src/__tests__/sectionStyleKeysRetired-13626.test.tsx`, across all seven arms. +> Note for contributors: `ObjectFormSection` in `@object-ui/types` no longer +> declares either key (objectui#7200), so an uncast `className: s.className` fails +> to compile — but a cast read (the shape the seven retired sites had) still +> would, and JSON metadata carries whatever an author wrote. The non-consumption +> is therefore pinned behaviourally, in +> `src/__tests__/sectionStyleKeysRetired-13626.test.tsx`, across all seven arms; +> the declaration side is pinned in `@object-ui/types`' +> `object-form-section-style-keys-undeclared.test.ts`. ### Tabbed field layout (`fieldTabs`) diff --git a/packages/plugin-form/src/ObjectForm.tsx b/packages/plugin-form/src/ObjectForm.tsx index 777bbed4a3..502c1b0400 100644 --- a/packages/plugin-form/src/ObjectForm.tsx +++ b/packages/plugin-form/src/ObjectForm.tsx @@ -312,11 +312,13 @@ export const ObjectForm: React.FC = ({ // real product need it gets an explicit controlled token surface, // not two leaked keys. // - // ⚠️ Re-adding the read does NOT require a cast to compile: - // `ObjectFormSection` (this repo's own `@object-ui/types`) still - // declares both keys, so a plain `className: s.className` type - // -checks. The omission is therefore pinned behaviourally, not by - // a source grep — `__tests__/sectionStyleKeysRetired-13626.test.tsx`. + // ⚠️ `ObjectFormSection` (this repo's own `@object-ui/types`) no + // longer declares either key (objectui#7200), so an uncast + // `className: s.className` fails to compile here. A CAST read — + // the shape the seven retired sites had — still would, and JSON + // metadata carries whatever an author wrote. The omission is + // therefore pinned behaviourally, not by a source grep — + // `__tests__/sectionStyleKeysRetired-13626.test.tsx`. })), defaultTab: schema.defaultTab, tabPosition: schema.tabPosition, diff --git a/packages/plugin-form/src/__tests__/sectionStyleKeysRetired-13626.test.tsx b/packages/plugin-form/src/__tests__/sectionStyleKeysRetired-13626.test.tsx index 4fa7c5f800..9dbd695fad 100644 --- a/packages/plugin-form/src/__tests__/sectionStyleKeysRetired-13626.test.tsx +++ b/packages/plugin-form/src/__tests__/sectionStyleKeysRetired-13626.test.tsx @@ -27,12 +27,19 @@ * * ## Why this pin is BEHAVIOURAL and not a source grep * - * ⚠️ The retired reads did not actually need their casts. `ObjectFormSection` - * (this repo's own `@object-ui/types`) still declares both keys, so a later - * "cleanup" writing a plain `className: s.className` — no `as any` in sight — - * type-checks and silently restores consumption. A grep for `as any` would stay - * green through exactly the regression this file exists to catch. So each row - * authors the keys and asserts the strings never reach the DOM. + * ⚠️ When this file was written the retired reads did not need their casts: + * `ObjectFormSection` (this repo's own `@object-ui/types`) still declared both + * keys, so a "cleanup" writing a plain `className: s.className` type-checked + * and silently restored consumption. objectui#7200 has since removed the two + * members (pinned at the type level in @object-ui/types' + * `object-form-section-style-keys-undeclared.test.ts`), so an UNCAST read now + * fails to compile — but a cast one (`(s as any).className`, the exact shape + * the seven retired sites had) still compiles, and metadata arriving as JSON + * carries whatever an author wrote regardless of the TypeScript face. A grep + * for `as any` would stay green through exactly the regression this file + * exists to catch. So each row authors the keys — as UNDECLARED keys, through + * a fixture type widened at this boundary and nowhere else — and asserts the + * strings never reach the DOM. * * ## The liveness control — why every row asserts something PRESENT * @@ -62,6 +69,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, cleanup, waitFor } from '@testing-library/react'; import React from 'react'; import { registerAllFields } from '@object-ui/fields'; +import type { ObjectFormSection } from '@object-ui/types'; import { ObjectForm } from '../ObjectForm'; import { DrawerForm } from '../DrawerForm'; @@ -82,7 +90,19 @@ const GRID_CLASS = 'os13626-authored-grid-class'; * against the stacked layout — measured, not assumed: it was a one-section * fixture that made the tabbed and wizard rows fail their liveness wait here. */ -const sections = () => [ +/** + * The authored fixture shape: `ObjectFormSection` plus the two keys it no + * longer declares (objectui#7200). Widened HERE, at the fixture boundary, and + * nowhere else — the pin's subject is that an author CAN still put these keys + * into JSON metadata and the renderer must not honour them. Deleting the keys + * from the fixture instead would make every row below pass for free. + */ +type AuthoredSectionWithRetiredStyleKeys = ObjectFormSection & { + className: string; + gridClassName: string; +}; + +const sections = (): AuthoredSectionWithRetiredStyleKeys[] => [ { name: 'always', label: 'Always', diff --git a/packages/types/src/__tests__/object-form-section-style-keys-undeclared.test.ts b/packages/types/src/__tests__/object-form-section-style-keys-undeclared.test.ts new file mode 100644 index 0000000000..a41393a96d --- /dev/null +++ b/packages/types/src/__tests__/object-form-section-style-keys-undeclared.test.ts @@ -0,0 +1,152 @@ +/** + * 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#7200 — `ObjectFormSection` declares neither `className` nor + * `gridClassName`. This is the declared-but-inert remainder of + * objectstack#13626. + * + * ## The ruling this file inherits + * + * objectstack#13626 (maintainer ruling 2026-09-01, director decision batch C, + * verbatim 「同意」) retired every read of the two keys off an authored + * form-view section: "retire the reads … Declaring the keys was weighed and + * not adopted: it would formally invite free Tailwind strings into authored + * metadata, the exact class the boundary exists to keep out." PR #7198 removed + * the seven `as any` reads and pinned the non-consumption behaviourally + * (plugin-form's `sectionStyleKeysRetired-13626.test.tsx`). What it left was + * the mirror image of the original defect: `ObjectFormSection` — the type an + * author's `ObjectFormSchema.sections` entry is checked against — still + * declared both keys, with doc comments promising a wrapper class nothing + * delivered. An author could write either key, have it type-check, and get + * nothing. objectui#7200 removes the two members so the authored-metadata type + * agrees with the spec's strict `FormSectionSchema`, which declares neither. + * + * ## Why a DELETION and not a `?: never` tombstone + * + * The house pattern for a retired key on a zod-mirrored, NON-strict surface is + * a tombstone — declared, unwritable, and refused at parse with a migration + * note (`component-input-retired-constraint-keys.test.ts`, `chart-inline-data- + * retired.test.ts`). Neither half of that reasoning holds here: + * + * - There is no parse door. `ObjectFormSection` has no zod mirror at all — + * `ObjectFormSchema` in `zod/objectql.zod.ts` does not declare `sections` + * — so there is nothing to attach a refusal to and nothing that would + * silently strip a deleted key. + * - The ruling's rationale is specifically "do not declare". A `?: never` + * member is still a declaration: it shows up in completion and in the + * generated `.d.ts`, and it is exactly the formal invitation the ruling + * declined to extend. + * + * ## Which types are NOT touched, deliberately + * + * The five per-layout config types in @object-ui/plugin-form — + * `ModalFormSectionConfig`, `SplitFormSectionConfig`, TabbedForm's + * `FormSectionConfig`, `WizardStepConfig`, `DrawerFormSectionConfig` — keep + * their own `className` / `gridClassName`. Their renderers read them uncast for + * programmatic React mounts, which the authorable boundary does not govern. + * `ObjectForm` rebuilds `sections` key by key on its way to those layouts and + * copies neither key, so the authored path stays sealed. + * + * ## What the `@ts-expect-error` directives prove + * + * `ObjectFormSection` carries no index signature and extends nothing, so an + * annotated object literal is subject to excess-property checking: an authored + * `className` is a compile error AT THE AUTHORING SITE. This package + * type-checks its tests through `tsconfig.test.json`, so re-declaring either + * member fails the build on the unused directive (`TS2578`). The direction was + * proven by ablation before this file was trusted: restoring the two members + * turned exactly the two directive lines below red and nothing else — see the + * objectui#7200 PR for the run. + * + * ## The contrast rows (liveness control) + * + * A refusal row alone is satisfied by a type that refuses EVERYTHING. So one + * row authors every key the type does declare, without a directive, and a + * `keyof` census names the full declared vocabulary — an addition or a removal + * anywhere on `ObjectFormSection` is then a deliberate edit to this file, not a + * drift the type system waves through. + */ + +import { describe, it, expect } from 'vitest'; +import type { ObjectFormSection } from '../objectql'; + +/** Every key `ObjectFormSection` declares today, spelled once. */ +const DECLARED_KEYS = [ + 'name', + 'label', + 'description', + 'collapsible', + 'collapsed', + 'columns', + 'pane', + 'fields', + 'visibleWhen', +] as const; + +describe('objectui#7200 — `ObjectFormSection` does not declare the retired section style keys', () => { + it('refuses an authored `className` at the authoring site', () => { + const section: ObjectFormSection = { + label: 'Basic Info', + fields: ['name', 'email'], + // @ts-expect-error `className` is not an authorable form-section key (objectstack#13626 / objectui#7200) + className: 'os7200-authored-section-class', + }; + expect(section.fields).toEqual(['name', 'email']); + }); + + it('refuses an authored `gridClassName` at the authoring site', () => { + const section: ObjectFormSection = { + label: 'Basic Info', + fields: ['name', 'email'], + // @ts-expect-error `gridClassName` is not an authorable form-section key (objectstack#13626 / objectui#7200) + gridClassName: 'os7200-authored-grid-class', + }; + expect(section.fields).toEqual(['name', 'email']); + }); + + it('keeps every live sibling key writable without a directive — the contrast', () => { + // No `@ts-expect-error` anywhere in this literal. If any line here ever + // needs one, the removal over-reached and took a consumed key with it. + const section: ObjectFormSection = { + name: 'basic', + label: 'Basic Info', + description: 'Name and contact', + collapsible: true, + collapsed: false, + columns: 2, + pane: 'primary', + visibleWhen: '${record.kind === "person"}', + fields: ['name', { name: 'email', type: 'email', label: 'Email' }], + }; + expect(section.columns).toBe(2); + expect(section.pane).toBe('primary'); + }); + + it('declared-key census: the full authored section vocabulary, so a re-addition is a deliberate edit here', () => { + // `Record` pins the census in BOTH + // directions at compile time: a key added to the type is a missing + // property here; a key removed from the type is an excess property here. + // Re-declaring `className` on the type therefore fails this literal, on + // top of the unused directive above. + const census: Record = { + name: true, + label: true, + description: true, + collapsible: true, + collapsed: true, + columns: true, + pane: true, + fields: true, + visibleWhen: true, + }; + expect(Object.keys(census).sort()).toEqual([...DECLARED_KEYS].sort()); + expect(DECLARED_KEYS).not.toContain('className'); + expect(DECLARED_KEYS).not.toContain('gridClassName'); + }); +}); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 20f6302740..1b57cd5677 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -1125,17 +1125,24 @@ export interface ObjectFormSection { */ visibleWhen?: string | { dialect?: string; source: string }; - /** - * Custom CSS class for the section's wrapper (Card, when the form variant - * renders sections as cards; the divider header, for the flat/simple path). - */ - className?: string; - - /** - * Custom CSS class for the section's field grid. Only used by form variants - * that render sections as Card chrome (Modal/Split/Tabbed/Wizard). - */ - gridClassName?: string; + // `className` / `gridClassName` are deliberately NOT declared here + // (objectui#7200 — the declared-but-inert remainder of objectstack#13626, + // maintainer ruling 2026-09-01: "retire the reads … Declaring the keys was + // weighed and not adopted: it would formally invite free Tailwind strings + // into authored metadata, the exact class the boundary exists to keep out"). + // This is the AUTHORED-metadata section type; `@objectstack/spec`'s + // `FormSectionSchema` is a strictObject with neither key, and the renderer + // reads neither off an authored section. An annotated literal carrying one + // now fails at the authoring site instead of type-checking into a no-op. + // + // The per-layout config types in @object-ui/plugin-form + // (`ModalFormSectionConfig`, `SplitFormSectionConfig`, TabbedForm's + // `FormSectionConfig`, `WizardStepConfig`, `DrawerFormSectionConfig`) keep + // their own members: those are read for programmatic React mounts, which the + // authorable boundary does not govern. Pinned at the type level in + // `__tests__/object-form-section-style-keys-undeclared.test.ts`; the + // behavioural half (an authored string never reaches the DOM) stays in + // plugin-form's `__tests__/sectionStyleKeysRetired-13626.test.tsx`. } /**