Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/7200-object-form-section-style-keys-undeclared.md
Original file line numberDiff line numberDiff line change
@@ -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`.
20 changes: 13 additions & 7 deletions packages/plugin-form/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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`)

Expand Down
12 changes: 7 additions & 5 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -312,11 +312,13 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
// 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,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
*
Expand DownExpand Up@@ -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';

Expand All@@ -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',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<keyof ObjectFormSection, true>` 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<keyof ObjectFormSection, true> = {
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');
});
});
29 changes: 18 additions & 11 deletions packages/types/src/objectql.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`.
}

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/7200-object-form-section-style-keys-undeclared.md
Original file line numberDiff line numberDiff line change
@@ -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`.
20 changes: 13 additions & 7 deletions packages/plugin-form/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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`)

Expand Down
12 changes: 7 additions & 5 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -312,11 +312,13 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
// 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,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
*
Expand DownExpand Up@@ -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';

Expand All@@ -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',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<keyof ObjectFormSection, true>` 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<keyof ObjectFormSection, true> = {
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');
});
});
29 changes: 18 additions & 11 deletions packages/types/src/objectql.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`.
}

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/7200-object-form-section-style-keys-undeclared.md
Original file line numberDiff line numberDiff line change
@@ -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`.
20 changes: 13 additions & 7 deletions packages/plugin-form/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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`)

Expand Down
12 changes: 7 additions & 5 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -312,11 +312,13 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
// 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,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
*
Expand DownExpand Up@@ -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';

Expand All@@ -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',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<keyof ObjectFormSection, true>` 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<keyof ObjectFormSection, true> = {
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');
});
});
29 changes: 18 additions & 11 deletions packages/types/src/objectql.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`.
}

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/7200-object-form-section-style-keys-undeclared.md
Original file line numberDiff line numberDiff line change
@@ -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`.
20 changes: 13 additions & 7 deletions packages/plugin-form/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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`)

Expand Down
12 changes: 7 additions & 5 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -312,11 +312,13 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
// 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,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
*
Expand DownExpand Up@@ -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';

Expand All@@ -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',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<keyof ObjectFormSection, true>` 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<keyof ObjectFormSection, true> = {
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');
});
});
29 changes: 18 additions & 11 deletions packages/types/src/objectql.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`.
}

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/7200-object-form-section-style-keys-undeclared.md
Original file line numberDiff line numberDiff line change
@@ -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`.
20 changes: 13 additions & 7 deletions packages/plugin-form/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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`)

Expand Down
12 changes: 7 additions & 5 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -312,11 +312,13 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
// 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,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
*
Expand DownExpand Up@@ -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';

Expand All@@ -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',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<keyof ObjectFormSection, true>` 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<keyof ObjectFormSection, true> = {
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');
});
});
29 changes: 18 additions & 11 deletions packages/types/src/objectql.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`.
}

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/7200-object-form-section-style-keys-undeclared.md
Original file line numberDiff line numberDiff line change
@@ -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`.
20 changes: 13 additions & 7 deletions packages/plugin-form/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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`)

Expand Down
12 changes: 7 additions & 5 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -312,11 +312,13 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
// 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,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
*
Expand DownExpand Up@@ -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';

Expand All@@ -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',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<keyof ObjectFormSection, true>` 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<keyof ObjectFormSection, true> = {
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');
});
});
29 changes: 18 additions & 11 deletions packages/types/src/objectql.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`.
}

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/7200-object-form-section-style-keys-undeclared.md
Original file line numberDiff line numberDiff line change
@@ -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`.
20 changes: 13 additions & 7 deletions packages/plugin-form/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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`)

Expand Down
12 changes: 7 additions & 5 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -312,11 +312,13 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
// 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,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
*
Expand DownExpand Up@@ -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';

Expand All@@ -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',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<keyof ObjectFormSection, true>` 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<keyof ObjectFormSection, true> = {
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');
});
});
29 changes: 18 additions & 11 deletions packages/types/src/objectql.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`.
}

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/7200-object-form-section-style-keys-undeclared.md
Original file line numberDiff line numberDiff line change
@@ -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`.
20 changes: 13 additions & 7 deletions packages/plugin-form/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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`)

Expand Down
12 changes: 7 additions & 5 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -312,11 +312,13 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
// 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,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
*
Expand DownExpand Up@@ -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';

Expand All@@ -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',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<keyof ObjectFormSection, true>` 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<keyof ObjectFormSection, true> = {
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');
});
});
29 changes: 18 additions & 11 deletions packages/types/src/objectql.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`.
}

/**
Expand Down
Loading