Skip to content

fix(plugin-form): hand a section's description to the default layout's divider - #9833

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-9779-section-description-default-layout
Sep 18, 2026
Merged

os-sales merged 1 commit into
mainfrom
claude/issue-9779-section-description-default-layout

Conversation

@os-sales

@os-sales os-sales commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes objectui#9779

Clause-②: no

The key is already declared on both sides (ObjectFormSection.description in this repo's own @object-ui/types, FormSection.description in @objectstack/spec) and is already rendered by other layout arms, and SectionDivider has always drawn one. Making the default layout read it relaxes no accepted set and enlarges no public surface. Re-declared here by me, as the seat asked; I reached the same reading it did, on the declarations above rather than on its say-so.

What was wrong

SimpleObjectForm's grouped branch — the layout a section-carrying object-form gets when it declares no formType — rebuilds every section key by key into a virtual section-divider row. That rebuild copied label, the ADR-0089 predicate, the objectui#6236 membership claim and the collapse pair, and not description. So the blurb never reached the divider, while the sibling label on the same member did — which is what made the miss invisible to an author.

One key copied onto that push. Nothing else in the renderer moved.

My own measurement of the arms — the card's table is NOT what I measured

⚠️ I re-measured rather than inheriting the card's list, and the reading differs from it. A throwaway probe mounted the same two sections (each carrying label + description) through the real ObjectForm for every arm, plus DrawerForm mounted directly, and read document.body.textContent. Probe deleted after measuring; it is not in this diff.

arm blurb rendered? the call it travels through
default (no formType) / simple ⛔ no, before this PR SimpleObjectForm's section-divider push — the fix
tabbed ✅ yes ObjectForm's tabbed section map to TabbedForm's tabGroups, out as fieldTabs[].description, rendered by the form renderer's field-tabs group header
wizard ✅ yes (current step only, which is the wizard's whole shape) ObjectForm's wizard step map, then FormSectionContainer's own description prop
split ✅ yes ObjectForm's split map, then paneFields' divider push, gated `section.label
modal ✅ yes ObjectForm's modal map, then the stacked-sections divider push gated `g.title
drawer ⛔ no — and this PR does not change it ObjectForm's drawer map copies the key, then DrawerForm's own divider push drops it again

⇒ it was two arms missing the key, not one. DrawerForm is deliberately untouched here: the seat's dispatch says not to widen into the other arms, DrawerForm's divider push is a separate site rather than a shared path, and an arm the card asserts is healthy turning out not to be is a finding for the seat to file, not something to fold into a one-arm repair. Handed back in my report.

The pin moved WITH the fix, and it is shown failing

objectFormSectionMembers-8071's sixth row pinned the drop as behaviour (objectui#8071 slice 11 wrote pins only and handed the drop back as a finding). It is rewritten in this same change onto the new behaviour, with its comment saying what it used to pin and which card changed it. It was ⛔ not weakened and ⛔ not deleted: it is still the only assertion watching this key, and it now reads the blurb off the divider row itself rather than off the form's whole textContent — an arrival assertion against textContent would be satisfied by the string rendering anywhere at all.

Ablation, run from the committed state so both legs are byte-provable, with a trap restoring on any exit:

  • HEAD blob of the renderer: 83a8fbd13c44626aeb5097b97847d41ab3ea1b9b
  • mutation = delete the one copied key. Landed on disk, proven by the anchor count going 1 to 0 (⛔ not by the editor's exit code), and by the mutated blob hashing 101ec9230ec6b175c4fd1318a9cfda82fbdfd164, different from HEAD's.
  • mutated leg: Tests 1 failed | 6 passed (7), and the one red is exactly row 6 — AssertionError: … expected [] to deeply equal [ 'Totals as invoiced' ]. Every other row, the boundary row included, stayed green.
  • restore leg: git checkout HEAD -- on the file (⛔ not a bare git checkout --, which restores from the polluted index). Restored blob 83a8fbd13c44626aeb5097b97847d41ab3ea1b9b — equal to HEAD's — git diff HEAD empty, anchor count back to 1, and the pin green again at Tests 7 passed (7).

No build was needed for either leg and none was used: *.test.tsx runs under the root vitest projects, whose alias map redirects every @object-ui/* specifier to that package's src, so the mutation was read directly. (The dist-pin family is the deliberate exception, and this file is not one of them.)

What else asserts the old behaviour — swept, two hits, both moved

  • Mechanical sweep: the whole packages/plugin-form suite at the fixed tree — Test Files 96 passed (96), Tests 920 passed | 1 skipped (921). Nothing else in the package went red, so nothing else in it was asserting the drop.
  • Text sweep across the tree for the card's sentinel, and for prose pairing description with drop/never-reaches/silently wording: exactly two carriers. Row 6 itself, and the member-pin ledger entry for object-form.sections in registry-inputs-spec-parity, whose pins prose spelled out the LIMIT in words. The ledger prose is not compared mechanically (only the pin file's existence and whether it names the block and the key are), so it would have gone on describing a behaviour that no longer exists — commandment 完善设计器的每一个细节 #9's failure mode. Corrected in the same change; that test still passes, Tests 199 passed (199).
  • Six further files outside plugin-form that mount an object-form and mention description were read individually: all of them are about a FIELD's description or a form-level one, none asserts a section blurb's absence.

⇒ ⛔ row 6 was not the only carrier, but it was the only assertion.

Changeset grade — measured from a build, not assumed

A changeset is owed and it is a real bump, ⛔ not an empty-frontmatter declaration. @object-ui/plugin-form patch.

Measured on the corpus that package's files array actually publishes (dist plus the three markdown/licence entries), built on this branch after building the dependency closure:

  • The fix reaches it. The compiled SimpleObjectForm divider push in dist/index.js — the one carrying colSpan: 4, the collapse pair and the onToggle closure — now carries description: r.description. The source spelling does not survive minification, so the property on the compiled push is the marker, ⛔ not a grep for the source line.
  • Lit control, same corpus: section-divider is present in dist/index.js, dist/index.umd.cjs and two .d.ts files — the corpus is one this grep can see into.
  • Absent control, same corpus: the pin's sentinel string Totals as invoiced appears nowhere in dist — the grep can return zero, so the hit above is not a grep that hits everything.
  • Second absent control, same artifact: three structurally identical divider pushes in the SAME bundle (the drawer arm's two and the modal derived arm's) carry no description property at all — so the bundler is not inventing the key, and the property on the fixed site got there from the source change.

The boundary this PR deliberately did NOT move

The divider row exists only for a member that yields a heading — a name or a label — which is the "untitled trailing bucket renders flat" rule the file's own comment states and row 3 pins. A member carrying a description and neither of those therefore still draws no divider and still drops its blurb, whereas split / modal / wizard / tabbed render one for it.

I did not widen that gate, because it decides more than this key: it also gates the ADR-0089 predicate row and the objectui#6236 membership claim, and the collapse pair hangs off the same condition — so a headingless section that gained a divider would newly have its predicate gate its whole group, and a collapsed: true on it would newly take its fields out of the DOM. Those are rulings about other keys. The shape is pinned as behaviour in a new row beside row 6 — the same way row 6 recorded the drop — and handed back as a finding.

Tests

  • pnpm exec vitest run packages/plugin-form/src/__tests__/objectFormSectionMembers-8071.test.tsx — Tests 7 passed (7).
  • pnpm exec vitest run packages/plugin-form/ — Test Files 96 passed (96), Tests 920 passed | 1 skipped (921).
  • pnpm exec vitest run apps/console/src/__tests__/registry-inputs-spec-parity.test.ts — Tests 199 passed (199).
  • pnpm --filter @object-ui/plugin-form type-check — exit 0; it runs tsc --noEmit AND tsc -p tsconfig.test.json, so the rewritten pin is type-checked too. The copied key needs no cast: ObjectFormSection.description is declared, so a rename would be caught by the compiler.
  • pnpm --filter '@object-ui/plugin-form^...' build then pnpm --filter @object-ui/plugin-form build — both exit 0 (the closure first; the package cannot typecheck its own build against unbuilt dependencies).
  • Gates: check:changeset-presence (red before the changeset, green after), check:control-bytes, check:new-line-citations (0 new), check:changeset-claims, check:pending-changeset-literals — all exit 0, each captured to a file BEFORE any pipe.
  • eslint --no-inline-config --format json over the three changed files: 0 errors. The 58 + 2 warnings it reports are pre-existing no-explicit-any hits in helpers this change does not touch; the diff adds none.
  • Every heavy run went through the container's shared verify lock under one stable slot name, and each verdict above is that wrapper's own VERDICT command-exit line, ⛔ never a bare exit status read through a pipe.

Acceptance notes

  • Serial constraint re-taken immediately before my first write: all 16 open PRs enumerated (release PR included) and each one's file list read — zero touch packages/plugin-form/src. Re-taken, ⛔ not inherited from the claim comment.
  • The diff reaches one file outside the dispatched surface: the member-pin ledger prose in apps/console, for the reason in the sweep section. It is one string literal inside a test; no published behaviour of @object-ui/console changes.
  • Refs, not the closing keyword, on purpose: the card's own repro and its judgement are discharged, but the card states the drop in general terms and the headingless shape above still drops the key. The seat owns whether that shape is this card or a new one; ⛔ I did not close the card from here, and ⛔ I filed nothing.
  • Out of scope, ⛔ not filed, reported in my hand-back: the drawer arm drops the same key at DrawerForm's own divider push (reproducible, measured above); the headingless-section boundary; and the card's own arm table, which says five arms render the key when four do.
  • Draft, as dispatched. ⛔ Not flipped ready, ⛔ not enqueued, ⛔ no label written by me.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UanLVj6xvbS6puBCewLr8L


Generated by Claude Code


Generated by Claude Code

…t's divider

`SimpleObjectForm`'s grouped branch — the layout a section-carrying
`object-form` gets when it declares no `formType` — rebuilds each section key
by key into a virtual `section-divider` row. The rebuild copied `label`, the
ADR-0089 predicate, the membership claim and the collapse pair, and not
`description`, so the key was dropped on the path an author reaches by
default while `SectionDivider` (the component that row renders as) has always
drawn a blurb and the `tabbed` / `wizard` / `split` / `modal` rebuilds copied
one. The sibling `label` on the same member arrived, which is what made the
miss invisible.

The `if (label)` gate is deliberately not widened with it: that gate also
decides the predicate row and the membership claim, so a member carrying a
`description` and neither `name` nor `label` still draws no divider. Pinned
as behaviour beside the fix.

`objectFormSectionMembers-8071` row 6 pinned the drop; it is rewritten onto
the new behaviour in this same change, reading the blurb off the divider
itself rather than off the form's `textContent`, and a new row pins the
boundary above. The member-pin ledger entry that described the old row in
prose is corrected with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UanLVj6xvbS6puBCewLr8L
@github-actions

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 2 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/8067-component-input-member-kind.md

  • names apps/console/src/__tests__/registry-inputs-spec-parity.test.ts → apps/console/src/__tests__/registry-inputs-spec-parity.test.ts — edited by this change

    A registration's type: 'array' said a value was a list and stopped there, so a member that drifted from @objectstack/spec was invisible to every layer that reads a declaration. page:header.actions is the measured cost: the contract declares z.array(z.string()) ("Action IDs"), the renderer read the members as ActionDef objects, and the repo-wide parity gate in apps/console/src/__tests__/registry-inputs-spec-parity.test.ts stayed green for the whole life of the drift because both sides carried the key and neither could say what was inside it. What settled it was a maintainer ruling, not a test — and even after the fix, "these are ids" survived only as English in the registration's description.

.changeset/8738-object-form-fields-description.md

  • names ObjectForm.tsx → packages/plugin-form/src/ObjectForm.tsx — edited by this change

    The registration declared { name: 'fields', type: 'array' } with no description, so an author had nowhere to read that this key's members are bare field names — a different vocabulary from sections[].fields, which also accepts the spec FormFieldSchema object (identity key field, e.g. { field: 'note', colSpan: 2 }). Moving one of those objects to the top-level fields resolves to no name and is silently skipped by SimpleObjectForm (ObjectForm.tsx) and by buildFlatFields (flatFields.ts, shared by the drawer/modal presentations) — no throw, no warning, no empty-state. Behaviour is unchanged; this only adds the description text an author would need to avoid the drop before writing it.

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a born-false claim that spells no line address at all (objectui#9495 coordinated one by ORDINAL — "a grep finds that member first" — and deciding that means reading what the sentence means), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Compared the checked-out tree with 26ac50369 (merge-base with origin/main): 3 file(s) changed outside .changeset/, read against 1149 pending declaration(s) that publish a body (1681 pending in total). · run

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3049.5 KB 3104.5 KB
Main entry chunk (gzip) 145.7 KB 350 KB
Entry file index-DBc4Q2cS.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 545.99KB 130.71KB
core (index.js) 8.94KB 3.59KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 215.98KB 59.97KB
fields (index.js) 249.23KB 62.88KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.83KB 10.95KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.92KB 14.22KB
plugin-charts (index.js) 71.61KB 20.05KB
plugin-chatbot (index.js) 195.34KB 46.51KB
plugin-dashboard (index.js) 131.44KB 34.65KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 253.28KB 65.87KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.74KB 34.16KB
plugin-gantt (index.js) 167.62KB 41.26KB
plugin-grid (index.js) 212.61KB 57.90KB
plugin-kanban (index.js) 48.10KB 14.94KB
plugin-list (index.js) 112.74KB 27.70KB
plugin-map (index.js) 21.48KB 6.99KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.41KB 11.93KB
plugin-timeline (index.js) 30.07KB 8.74KB
plugin-tree (index.js) 10.58KB 3.72KB
plugin-view (index.js) 85.05KB 21.01KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 104.82KB 34.67KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.04KB 5.36KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-sales
os-sales marked this pull request as ready for review September 18, 2026 08:37
@os-sales
os-sales added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit 4d963a2 Sep 18, 2026
38 checks passed
@os-sales
os-sales deleted the claude/issue-9779-section-description-default-layout branch September 18, 2026 08:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants