…ow (objectui#12548) (#12556)
Fixes #12548
Clause-②: no — no type, prop, export or spec declaration changes;
`sectionedGridLayout` / `TWELVE_TRACK_GRID_COLS` live in `autoLayout.ts`
and are not re-exported from the package entry.
## What changed
A sectioned form draws all its sections in ONE field grid, sized by the
widest section, and each field spanned `Math.round(grid /
sectionColumns)` cells of it. When a section's count did not fit that
grid, the section drew the wrong number of fields per row. Measured on
`main` at `9c2785b`, ModalForm, 2 / 3 / 1 form:
| field | grid | cell |
|---|---|---|
| `columns: 2` field | `grid-cols-1 @md:grid-cols-2 @2XL:grid-cols-3` |
`@md:col-span-2` → **one per row at every tier** |
| `columns: 3` field | same | (none) → 2 / 3 per row (correct) |
| `columns: 1` field, `span: 'full'` | same | `@md:col-span-2
@2XL:col-span-3` (correct) |
| `columns: 3` beside `columns: 4` | `… @4XL:grid-cols-4` | (none) →
**four per row at `@4xl`** |
ObjectForm, SplitForm, TabbedForm and WizardForm (under a form-level
`columns: 3`) drew the same classes.
**One shared rule**, `sectionedGridLayout(formColumns,
sectionColumns[])` in `packages/plugin-form/src/autoLayout.ts`, now
called by all five hosts in place of the per-host `applyAutoColSpan` +
`containerGridColsFor` pair:
- **Every section is 1 or the grid width:** the rule returns the old
`CONTAINER_GRID_COLS` grid and the old `applyAutoColSpan` spans.
Single-density forms, and forms with no section `columns`, are therefore
byte-identical.
- **Otherwise:** the grid is `grid gap-4 grid-cols-1 @md:grid-cols-12`,
and each field's `colSpan` counts twelfths of the row:
- `span: 'full'` and wide widgets take 12;
- an authored `colSpan` counts that many of its own section's columns,
clamped to them;
- any other field takes 12 / the section's columns.
- A section's count is still clamped to the form grid, so a form-level
`columns` still outranks it.
**`@object-ui/components` form renderer** (where the span classes are
drawn): on a container carrying `@md:grid-cols-12`, a twelfths `colSpan`
maps through a literal table, `TWELVE_TRACK_SPAN_CLASS`. The table keeps
each count's existing step ladder:
| section `columns` | twelfths | classes | per row at `@md` / `@2xl` /
`@4xl` |
|---|---|---|---|
| 1 | 12 | `@md:col-span-12` | 1 / 1 / 1 |
| 2 | 6 | `@md:col-span-6` | 2 / 2 / 2 |
| 3 | 4 | `@md:col-span-6 @2XL:col-span-4` | 2 / 3 / 3 |
| 4 | 3 | `@md:col-span-6 @2XL:col-span-4 @4XL:col-span-3` | 2 / 3 / 4 |
| authored 2 of 3 | 8 | `@md:col-span-12 @2XL:col-span-8` | |
| authored 3 of 4 | 9 | `@md:col-span-12 @4XL:col-span-9` | |
Below `@md` the form stays one column. Every other container is rendered
by the old code path, unchanged. The literal `'@md:grid-cols-12'` sits
in components' source, so the published `style.css` carries the utility
too.
## Verification record (at `88bfef0`)
- New pins,
`plugin-form/src/__tests__/mixedSectionColumns-12548.test.tsx` (25
tests):
- per host (modal / object / split / tabbed, plus wizard under a
form-level `columns: 3`), a 2 / 3 / 1 form reads 2 / 3 / 1 per row at
`@4xl` and 2 / 2 / 1 at `@md`, and the 3-column section reads 3 at
`@2xl`;
- `span: 'full'` takes the row; base `grid-cols-1`; 3-beside-4 reads
three per row;
- CONTROL: all `columns: 2` and all `columns: 3`, per host, keep the
literal grid and span classes `main` drew;
- a drift guard: each count's ladder on the 12-track grid equals
`min(columns, tier)` against `CONTAINER_GRID_COLS[4]`'s tiers;
- pure-rule pins.
- Renderer pins,
`components/src/renderers/form/__tests__/form-twelve-track-span-12548.test.tsx`
(7), including a CONTROL that the 1–4 containers emit as before.
- Before the fix, the new file ran 6 failed / 8 passed on `main`: the
mixed pins red, the CONTROLs green.
- Ablation 1: `sectionedGridLayout` forced to the old path. 12 mixed
pins red, 13 green (all 8 CONTROLs among them). Restore was proven by
blob hash and an empty `git diff HEAD`.
- Ablation 2: the renderer's 12-track detection turned off. 16 red, 16
green, restored the same way.
- `pnpm --filter '@object-ui/plugin-form^...' build` → 0
- `pnpm --filter @object-ui/plugin-form type-check` → 0
- `pnpm --filter @object-ui/components type-check` → 0
- tsc over both new test files → 0 (the package type-checks exclude
tests)
- Whole `packages/plugin-form` suite: 184 files, 2269 passed, 1 skipped
→ 0
- Every suite that renders a host or touches the census, plus
`components/src/renderers/form/__tests__/*`: 225 files → 0
- packages: 208 files, 3067 passed, 17 skipped
- apps / examples / scripts: 17 files, 1465 passed
- `check:control-bytes`, `check:new-line-citations` (0 new),
`check-changeset-presence`, `check-changeset-no-major`,
`check:changeset-claims`, `check:pending-changeset-literals`,
`check:test-path-roots`, `check:vi-mock-specifiers`,
`check:vi-mock-inherit`, `check:vi-mock-override-shape`,
`check:unreferenced-sources`, eslint on the 9 changed files (0 errors) →
all 0
- Tailwind v4 compile of `apps/console/src/index.css` emits
`@md:grid-cols-12`, `@md:col-span-{6,12}`, `@2xl:col-span-{4,8}` and
`@4xl:col-span-{3,9}`; `@md:col-span-8` and `@2xl:col-span-12` are
absent, as expected. Compiling `packages/components/src/index.css` also
emits `@md:grid-cols-12`.
- NOT MEASURED locally: `check:readme-exports` (needs every package's
dist; no export changed) and `performance-budget` (a full console
build). Both are left to CI.
## What the landing seat's browser reading should show
HotCRM Leads → New. Pre-merge reading: the HotCRM-shaped DOM rendered by
`ModalForm`, measured in Chromium with the console's Tailwind build:
| dialog | Contact Information (2) | `last_name` (`span: 'full'`) | Lead
Classification (3) | Company (2) | Additional (1) |
|---|---|---|---|---|---|
| 1368px, before | **1 per row, 875px** | 1320px | 3 per row, 429px | 1
per row, 875px | 1320px |
| 1368px, after | **2 per row, 652px each** | 1320px | 3 per row, 429px
(unchanged) | 2 per row, 652px | 1320px |
| 1024px, after | 2 per row, 480px | 976px | 3 per row, 315px
(unchanged) | 2 per row, 480px | 976px |
| 640px, after | 2 per row, 288px | 592px | 2 per row, 288px (unchanged
ladder) | 2 per row | 592px |
| 400px, after | 1 per row | 1 per row | 1 per row | 1 per row | 1 per
row |
At 1440 the seat should see:
- Contact Information at two fields per row, of one width;
- `last_name` on its own full row;
- Lead Classification at three per row;
- nothing empty in the right third except a row's natural remainder.
## Acceptance notes
- The dialog's size (`inferModalSize`, `modalSize`, `AppContent.tsx`)
and the no-section-`columns` default are untouched. objectui#12521 and
PR #12546 keep them. #12546 touches `ModalForm.tsx` and `autoLayout.ts`
in different regions, so expect a merge of `main` in its rework round.
- In a mixed form an authored `colSpan` counts its SECTION's columns (2
in a 3-column section = two thirds of the row). Before, it counted cells
of the widest section's grid. A single-density form keeps the old
reading.
- Authored `colSpan: 2` in a `columns: 4` section of a mixed form
reduces to one half and reads half the row at every tier. On a 4-only
grid it reads full / two thirds / half.
- A section `gridClassName` in a tabbed or split form with mixed
densities sits beside twelfths spans. That combination was already
inconsistent before this change.
- objectui has no `scripts/pm/dispatch-gates.mjs`, so the gate list was
derived by hand from `package.json` `check:*` and the workflows.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01DN3fpUznSTqNMkk9nXd5BG
Co-authored-by: Claude <noreply@anthropic.com>
Fixes #12521
Clause-②: no. No type, prop, export or spec declaration changes: the console's interim is one module-private function in
AppContent.tsx, and the dialog's new helpers are module-private inModalForm.tsx.Implemented by the dispatched dev of the
domain:uiseat 1 PM loop, rework round 1, sessionhttps://claude.ai/code/session_01JZnppgHRp4sREcouhkQYDB. It executes letter B (comment 6203721639), its section-columnsfork (6204264665) and the interim ruled in 6205068311 on the maintainer's 「可以,12521 走快速方案」. This PR now closes the card. The interim's deletion is a follow-up card the seat files.What lands
1. The console's interim desktop bound (
@object-ui/app-shell)deriveRecordFlowSurface(installed@objectstack/spec18.0.0-next.1, re-exported by@object-ui/plugin-view) answerssize: 'full'for a create, edit, child-create or child-edit flow on an object with 12 or more authorable fields.AppContentspread that intomodalSize: 'full', so HotCRM Leads → New opened at 1368 × 810 (95vw).interimBoundedTaskFlowSurface, now wraps the derivation's answer at its one call site. It mapssize: 'full'tosize: 'auto', the shape letter B makes the spec derive (spec(record-surface): a field-heavy create/edit flow on desktop derives a bounded modal, not full-screen; mobile keeps full (objectui#12521 letter B) objectstack#23734). So the dialog is handed no derivedmodalSize, andModalForm's own bounded default decides:xlorlg, neverfull.deriveRecordFlowSurfacedirectly once objectui installs a spec carrying spec(record-surface): a field-heavy create/edit flow on desktop derives a bounded modal, not full-screen; mobile keeps full (objectui#12521 letter B) objectstack#23734.AppContent.recordDialogInterimBound-12521.test.tsxcalls the installedderiveRecordFlowSurfacefor a 14-field object on a desktopcreateflow, the wayAppContentcalls it (no viewport). While it answersfull, the test asserts the interim applies. Once it answers anything else, the test fails with a message naming the deletion. So the bump that installs the new spec cannot land with the interim in place. There is no skip or todo; it is a real assertion today.ModalForm's own presentation belowsm, and nomodalSizereaches it.2. The dialog's own defaults (
@object-ui/plugin-form,ModalForm)Size (round 1, unchanged). With no
modalSize, the dialog pickslgorxlfrom the grid it renders and its field count, neverfull. The rule isinferModalSize(min(columns, inferColumns(fieldCount))). An authoredmodalSize,'full'included, is used as written.Flat grid (round 1, unchanged). A form with no
columnsand no sections draws two per row from the container's@mdtier (grid gap-4 grid-cols-1 @md:grid-cols-2). Each field takes one cell; a wide kind or aspan: 'full'field takes the row. The object'sfieldGroupsfallback lays out the same way.Section density (new, the fork's renderer side). In a form that declares no
columns, a section that declares none draws two per row from@md. A declared count,1included, is drawn as written. The rule lives in two module-private helpers:dialogSectionColumnsgives a section's count: declared, else the form's declaredcolumns, else 2;sectionedDialogColumnsgives the grid width: the form'scolumns, else the widest section count.Both feed objectui#12548's
sectionedGridLayout. When every count fits the widest grid, the grid staysCONTAINER_GRID_COLS'. Otherwise it switches to the 12-track grid, where an undeclared section's fields span 6 at@mdand a wide kind orspan: 'full'spans 12. Below@mdit is one column, as before.columns: 1: the grid is two wide; the undeclared fields take one cell, thecolumns: 1fields@md:col-span-2.columns: 3:grid gap-4 grid-cols-1 @md:grid-cols-12. The undeclared fields carry@md:col-span-6, two per row at every tier. Thecolumns: 3fields carry@md:col-span-6 @2xl:col-span-4.columnsstill sets an undeclared section's count, exactly as before.maindraws.3. The merge with
mainmainwas merged in as a merge commit (no rebase, no force-push).ModalForm.tsxconflicted with PR #12556 (objectui#12548, the per-section 12-track grid) and PR #12571 (objectui#12559, the dialog-ownedSuspense). The resolution keeps both landed behaviours: every sectioned dialog lays out throughsectionedGridLayout, and the body'sSuspenseboundary is untouched. The round-1 CONTROL pin (a HotCRM-shapedcolumns: 2+columns: 3form) now asserts the classes PR #12556 draws onmain.Measurements behind the route
A1, the phone path.
AppContentcalls the derivation with noviewport, so it only ever receives the desktop answer. EveryModalFormsize class issm:-prefixed (sm:max-w-2xl,sm:max-w-5xl,sm:max-w-[95vw] sm:w-full).MobileDialogContent's base classes arefixed inset-0 w-full h-[100dvh]. So belowsmthe dialog is a full-screen sheet whatevermodalSizesays.modalSize: 'full'.A2, where the section default is applied: upstream, at the authoring producer.
@objectstack/specFormSectionSchema.columnsis.default(1). Probed on the installed spec 18.0.0-next.1, a section with nocolumns:FormViewSchema.parseanswers[1, 1, 3]for sections declaring nothing,1and3;defineView, the call HotCRM'slead.view.tsuses, answers[1, 1, 3]too;defineStackin strict mode returns the parse'sresult.data.objectui never parses form views client-side;
resolveFormViewLayouthandsobjectDef.form.sectionsthrough as served. So a section from an authored stack reaches the dialog DECLAREDcolumns: 1and draws one column. The undeclared rule is correct but takes effect only after the spec drops the default (spec(view): FormSectionSchema.columns drops default(1) — an undeclared section density is the renderer's (by available width), a declared one is the author's ceiling (objectui#12521 fork) objectstack#23740). It is not worked around: a declared1is never read as undeclared, and a pin holds that. NOT MEASURED: the HTTP leg (a live server's served JSON), and whether a view saved through the metadata API at runtime is parsed on save. Neither objectstack nor hotcrm is installed in this container.A3, the merge. Resolved as above; PR fix(plugin-form): each form section draws its own columns count per row (objectui#12548) #12556's and PR fix(plugin-form): a record dialog's collapsed section opens in place, not behind the console's loading screen (objectui#12559) #12571's own pins were re-run as CONTROLs, all green (see Tests).
A4, the no-section default against
default(1). With the fork ruled, the only remaining question is A2. A form with no sections never meetsFormSectionSchema, so its two-per-row default is unaffected. The fieldGroups fallback stamps its owncolumnson the sections it derives, so it is unaffected too.Tests (local, head
91fd2dbe68)Every vitest run went through objectstack's
os-verify-lock.sh.packages/app-shell/src/console/__tests__/AppContent.recordDialogInterimBound-12521.test.tsx(new, 8 cases), on a realAppContentmount with the realModalFormwhere a case reads the dialog:deriveRecordSurfacepages it);create: nomodalSizeis handed over, and the dialog issm:max-w-5xl, notsm:max-w-[95vw];edit,child-createandchild-edit: no derivedmodalSizeis handed over;smare identical to those of the old 95vw dialog, the sheet'sfixed inset-0 w-full h-[100dvh]included, and nomax-w-*reaches a phone;modalSize: 'full'handed to the dialog still opens at 95vw;full, and while it does the interim applies.packages/plugin-form/src/__tests__/dialogDefaultLayout-12521.test.tsx(14 cases, 5 new): the round-1 cases; the CONTROL re-expressed on PR fix(plugin-form): each form section draws its own columns count per row (objectui#12548) #12556's classes; and the density cases. Those are: undeclared besidecolumns: 1; undeclared besidecolumns: 3on the 12-track grid; acolumns: 3section draws three; every section atcolumns: 1(what the spec's parse writes today) draws one column; and a form-levelcolumnsstill sets an undeclared section's count.interimBoundedTaskFlowSurfacemade the identity through objectstack'sscripts/ablation-replace.mjs(anchor 1 → 0, blob91e724d1d8→a9cf6e490d), trap-protected. Result: 5 failed, 3 passed.expected 'full' to be undefined.91e724d1d8) andgit diff HEADis empty.size: 'auto', as a spec carrying spec(record-surface): a field-heavy create/edit flow on desktop derives a bounded modal, not full-screen; mobile keeps full (objectui#12521 letter B) objectstack#23734 would. The cell then fails with: "The installed @objectstack/spec no longer derivessize: 'full'for a field-heavy desktop task flow: spec(record-surface): a field-heavy create/edit flow on desktop derives a bounded modal, not full-screen; mobile keeps full (objectui#12521 letter B) objectstack#23734 is installed. Delete the interiminterimBoundedTaskFlowSurfacein packages/app-shell/src/console/AppContent.tsx, callderiveRecordFlowSurfacedirectly at its call site, and delete this file (objectui#12521, interim ruled in comment 6205068311).: expected 'auto' to be 'full'". Restore was proven the same way. No permanent test file.mixedSectionColumns-12548.test.tsxandform-twelve-track-span-12548.test.tsx;modalSectionToggleSuspense-12559.test.tsxandsectionedFormValues.test.tsx;autoLayout.test.tsandplugin-view'srecordSurface.test.ts.packages/plugin-form/plusform-twelve-track-span-12548.test.tsx: 187 files, 2294 passed, 1 skipped, vitest exit 0.VERDICT command-exit 0. The set is:packages/app-shellandapps/consoletest thatgit grep -l -E "ModalForm|useActionModal|openRecordForm|form=new|createTitle|editTitle|formType: .modal.|resolveFormViewLayout|deriveRecordFlowSurface"finds on the merged tree (40, the new file included);ObjectManagertests andObjectFormDesigner.test.tsx;packages/typesfiles that readModalForm, andplugin-view'sObjectView.formTitleI18nandrecordSurfacetests;git grep -l -E "census|ratchet|WRITE_SITES|AFFORDANCE_GRANTS") whose subject this diff touches (AppContent,ModalForm,autoLayout, the changesets, the docs page).type-checkfor@object-ui/plugin-formand@object-ui/app-shell: exit 0, afterturbo run build --filter='@object-ui/app-shell^...'(29 packages,VERDICT command-exit 0). Each runstsc --noEmitandtsc -p tsconfig.test.json, whose includesrc/**/*.test.tsxcovers both pin files.Gates (local subset, each exit code captured before any pipe)
All exit 0:
check-changeset-presence("6 source file(s) of 2 released package(s) changed, and this change declares 2 changeset(s)"),-no-major,-overwrite,-fixed,check:changeset-claims,check:pending-changeset-literals;check:control-bytesandcheck:new-line-citations("0 new citation(s)");check:doc-snippets("796 of 796 block(s) judged, 0 failed"),check-doc-links,check:doc-fences,check:doc-json,check:doc-types,check-doc-expression-carriage,check:doc-example-ids.check:doc-snippetsfirst answered PRECONDITION NOT MET (exit 2, read as not measured) and passed after its scoped build;check:spec-symbols,check:installed-pin-claims;check-test-path-rootsand the threevi.mockgates;no-explicit-any;check-governed-queue-guard --teston the nine paths: NOT GOVERNED.Left to CI: the full farm, including repo-wide
pnpm lintandperformance-budget.What the landing seat's browser reading should show
Expected values below come from the DOM the real
ModalFormrenders for a HotCRM-Leads-shaped form (sectionscolumns2 / 3 / 2 / 1 / 2,last_namespan: 'full'). It was measured in headless Chromium against a Tailwind 4.3.3 compile of that DOM's own classes, not the console's full sheet.HotCRM Leads → New at 1440 × 900: the dialog is within the
xlbound, 1024 × 810 (it was 1368 × 810). The sections are drawn as authored:last_nameon its own row at 926 px;columns: 2section two per row; Additional Information (columns: 1) one per row.Collapsed sections open in place.
At 390 × 844: the sheet is unchanged. It is a full-screen 390 × 844 sheet with every field 358 px wide, one per row, the same as before the interim.
Between
smandxl: at a 1024-wide window the dialog fills the width, 1024 px (it was 973 px at 95vw).Acceptance notes
columns: 1and draws one column. This PR does not work around that.FormView.modalSizenorFormView.columns.resolveFormViewLayoutpasses sections,contentLayoutand subforms only. So "an authored form-view size still wins" has no path at this host, before or after this PR. No producer writesFormView.modalSize: zero hits in hotcrm, objectstackexamples/, and objectuiexamples/andapps/. Noted, not filed; carrier: none (承接者:无).ObjectForm's page form,SplitForm,TabbedFormandWizardForm. This round is dialog-only, as round 1 was. Once spec(view): FormSectionSchema.columns drops default(1) — an undeclared section density is the renderer's (by available width), a declared one is the author's ceiling (objectui#12521 fork) objectstack#23740 lands, those hosts will meet undeclared sections too. Noted for the follow-up; carrier: the seat.plugin-formonly. The title is outside this round's write budget.plugin-form.mdx's "single-column in every host" sentence is loose forObjectForm's simple path, which falls back toinferColumns.Generated by Claude Code