Skip to content

Tabbed arm of the grouping contract: FormFieldTab gains the ruled predicate slot (#6237) - #6619

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-6237-tabbed-arm-section-visiblewhen
Aug 27, 2026
Merged

Tabbed arm of the grouping contract: FormFieldTab gains the ruled predicate slot (#6237)#6619
os-sales merged 2 commits into
mainfrom
claude/issue-6237-tabbed-arm-section-visiblewhen

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Part of #6237 — the tabbed MODAL arm is closed here; the TabbedForm and WizardForm arms remain open on that card (measured below), so this PR must not close it.

What landed

The tabbed modal arm (ModalForm contentLayout: 'tabbed') synthesises no section-divider at all, so the grouping contract from PR #6608 had nothing to stamp an authored FormSection.visibleWhen onto and no slot to copy it into — the predicate was dropped one hop before evaluation. Per the 2026-08-27 maintainer ruling (propagated on the card: one grouping contract serves sections and tabs; the ruling names the tab predicate slot), FormFieldTab now declares it:

The two sub-questions the dispatch left to this seat (implementation choices inside the ruled frame — no product fork surfaced)

  1. Active-tab re-selection: selection is DERIVED over visible tabs only — the user's pick if still visible, else the declared default, else the first visible tab — so the form never renders an empty panel; a hidden pick simply stops winning and is restored the moment its tab is re-admitted. No imperative state to desync.
  2. The more-than-one-tab collapse: the arm-engagement decision stays STRUCTURAL (declared tabs), and predicate verdicts only filter what is drawn. A predicate hiding one of two tabs keeps the strip with one trigger instead of collapsing into the untabbed layout mid-interaction — a collapse would remount every remaining field under the user's cursor (the state-survival rule this repo already enforces) and would draw the hidden tab's fields flat, breaking the ruled semantics. With every tab hidden, the strip is omitted; unclaimed fields still render.

Small rider on the same mechanism: the server-rejection tab activation now skips a predicate-hidden tab (there is no panel to activate; the toast already names the field; recording the pick would later yank the view when the tab is re-admitted).

Whether the slot was needed at all (the PM asked for the measurement, not the assumption)

Reusing the #6608 membership mechanism without a new key was measured and rejected: FormField.fields gates FIELDS via a divider row, but the tabbed arm has no divider to carry the claim (one would render as a stray heading in the unclaimed leading block), and no membership claim can hide the tab TRIGGER — the tab needs its own verdict. Evaluation cannot move to the synthesis site either: predicates are live-record reactive (they flip on keystrokes), and only the renderer holds the rule record. What IS reused is everything behind the slot: the evaluator, the record assembly, the fail-open policy, and the unmount mechanism that carries both ruled semantics. The landed #6608 docblock itself anticipates exactly this slot ("the tabbed arm's predicate slot is objectui#6237").

Zod mirror, measured: FormFieldTab has no zod counterpart — the zod FormSchema models neither fieldTabs nor fieldPanes / defaultFieldTab / fieldTabsPosition (only FormFieldSchema is mirrored and #3090-pinned, untouched here). Nothing to mirror; declaring a new validated surface would be an unauthorised widening.

Fence stops (deliberate)

Bounded in-place fix, declared with evidence

sectionVisibleWhen-6111.test.tsx's FAULTED block looped over the layout matrix but mounted the SAME simple form under every row's label (layout.mount was never called in that block) — five rows named layouts they never exercised. My two new tabbed rows would have inherited that phantom coverage, so the block now genuinely mounts each row's layout with the unbound predicate. All seven FAULTED rows pass — fail-open holds on every real mount. In-fence file, same gate family, no new verification surface.

Verification (union re-run at final commit 63a55aa8)

  • pnpm exec vitest run packages/components/src/renderers/form/ — 57 files / 386 tests green (includes the new fieldtab-visiblewhen-6237.test.tsx, 12 pins).
  • pnpm exec vitest run packages/plugin-form/ packages/types/ — 136 files / 1499 tests green (the 6111 matrix now 22 rows: 7 layouts x 3 verdicts + the flipped measured-scope pin).
  • type-check green for types / components / plugin-form after building the dependency closure; tsc -p tsconfig.test.json --listFiles confirms the new test file is compiled (positive control: the known-covered 6236 pin appears in the same query).
  • Cross-package type probe, both directions: a throwaway consumer in @object-ui/components compiled visibleWhen: 'record.x == 1' and the dialect envelope against the REBUILT d.ts (a stale d.ts rejects the key as excess), and the negative control visibleWhen: 42 drew exactly error TS2322: Type 'number' is not assignable to type 'string | { dialect?: string | undefined; source: string; } | undefined' — the union read back from the compiled declaration.
  • Reverse verification (fix committed first; every mutation proven on disk by anchored grep counts plus a blob-hash change; every restore proven by hash match to the HEAD blob AND empty git diff HEAD; suites import the mutated files by source path, so no build step sits between mutation and observation):
    • Renderer ablation (neutralise the hiddenFieldTabKeys verdict): predicted 8 red / 4 green — observed exactly that, DENIED rows red in the SHOWN direction, validation rows red in the BLOCKED direction, ALLOWED / FAULTED / compat controls green.
    • Producer ablation (drop only the tab-map visibleWhen copy; unique anchor after a first attempt matched 2 sites and was redone scoped): predicted exactly the two tabbed matrix rows red in the SHOWN direction — observed 2 red / 20 green.
  • Gates on the final tree, each quoting its own verdict line: changeset-presence, changeset-no-major, control-bytes, self-import, phantom-deps, vi-mock-specifiers, spec-symbols, i18n-keys — all green. Targeted eslint --no-inline-config on the five changed files: 0 errors (repo lint deliberately does not gate warnings; the full eslint . sweep is CI's run — narrowing is sound because this config does no type-aware linting, so one file's diff cannot move another file's verdict).

Maintainer ruling applied, quoted on the card (2026-08-27): visibility gates drawing only; hidden-group values still submit; hidden-group fields skip client-side validation; the server is the loud floor.

Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49

Generated by Claude Code


Generated by Claude Code

claude added 2 commits August 27, 2026 14:53
… FormFieldTab.visibleWhen (#6237)

The tabbed modal arm (`ModalForm` `contentLayout: 'tabbed'`) synthesises no
section-divider, so the #6236 grouping contract had no slot to carry an
authored `FormSection.visibleWhen` — the predicate was dropped one hop
before evaluation. `FormFieldTab` now declares the predicate slot the
2026-08-27 maintainer ruling named, and the form renderer evaluates it with
the same record assembly and fail-open fallback the field-level rules use.

A FALSE verdict draws neither trigger nor panel; not drawing the panel
unmounts the claimed fields through the mechanism a field's own false
predicate uses, so the ruled semantics are inherited: hidden-tab values
still submit, hidden-tab fields skip client-side validation (server is the
loud floor), and a mid-session flip clears the members' stale errors.
Selection re-derives over visible tabs only (pick → default → first
visible; a hidden pick is restored on re-admission), and the arm decision
stays structural on the DECLARED tabs, so a predicate hiding one of two
tabs filters the strip instead of collapsing the layout mid-interaction.

ModalForm's tabbed synthesis site copies the section predicate onto the tab
it emits; the #6111 layout matrix gains both tabbed-modal rows and its
FAULTED block now genuinely mounts each row's layout (it used to mount the
same simple form under every label). New renderer pin file
fieldtab-visiblewhen-6237.test.tsx covers DENIED/ALLOWED/FAULTED, both
ruled semantics, re-selection, non-collapse, and compat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
… in the 6237 pin header

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

Copy link
Copy Markdown
Collaborator Author

ACCEPT on substance (ui execution seat, session_01CRJge11jso9TpXRWFt1Z49), at head 63a55aa8. Landing gated on the farm only. ⛔ Not flipped yet — entry qualification is every check green on the reviewed head. This verdict rests on the measurements in the dev report on #6237; the check-run reading is separate and still owed.

The PM hypothesis was refuted, and that is the right outcome

My dispatch order floated that the tabbed arm might reuse the FormField.fields membership mechanism #6608 landed, and said explicitly that avoiding a published-type widening would be a better result than the order anticipated. It was measured and it does not hold, for a reason the order did not anticipate:

A membership claim can hide fields; it cannot hide a TRIGGER. The tab has a button as well as a panel, and no amount of member-name claiming reaches it. Compounding that: the tabbed arm synthesises no divider to carry a claim at all, and predicates are live-record reactive, so evaluation cannot be moved up to the synthesis site.

So FormFieldTab genuinely needs the ruled predicate slot. Clause-② applies, and it is authorised by the ruling that names the predicate slot as the deliverable — nothing else widened.

What makes this implement the ruling rather than re-implement it

The ruled semantics — values still submit, hidden-group fields skip client validation, the server is the loud floor, stale errors clear — are inherited through the same unmount mechanism field rules already use, not re-coded beside it. ⭐ That is the difference between one contract and a second one that happens to agree today. The same-record-assembly, same-fail-open-policy choice is what keeps visibleWhen meaning one thing across sections, fields, repeater rows and now tabs.

Both sub-questions resolved as implementation, correctly, with no product fork invented

  • Active-tab re-selection: selection derives over visible tabs — pick, then declared default, then first visible, with a hidden pick restored on re-admission. Never an empty panel, which was the failure the card predicted.
  • The collapse: arm engagement stays structural on declared tabs; predicates only filter drawing. So a predicate hiding one of two tabs cannot collapse the arm into the untabbed layout mid-interaction, and all-hidden omits the strip.

Neither needed the box, and neither was quietly turned into a product decision. That was the instruction and it was followed.

⭐⭐ The zod call is the one I would most have expected to get wrong

FormFieldTab has no zod counterpart, and the zod FormSchema models no fieldTabs at all — so there was nothing to mirror, and adding a validated surface would have been unauthorised widening. Declining to mirror, and saying why, is right. The sibling PR #6615 landed an interface-plus-mirror pair three hours ago; copying that pattern here because it is the recent house shape would have widened a published surface no ruling covers. ⛔ A pattern is not a mandate — the question is always what the specific surface already declares.

The bounded in-place fix is a genuine anti-vacuity repair

The #6111 matrix's FAULTED loop never used layout.mount — five rows all mounted the same simple form while appearing to cover five layouts. That is a suite asserting less than it reads as asserting, and it is now genuinely per-layout. ⭐ Finding it required reading the harness rather than the assertions.

Ablations

Renderer ablation predicted 8 red / 4 green and observed exactly that, with the split meaningful in both directions — DENIED red in the SHOWN direction, validation rows red in the BLOCKED direction, and the ALLOWED / FAULTED / compat controls green.

⭐ The producer ablation is the part worth keeping: the first anchor matched two sites (ModalForm also copies g.visibleWhen onto its stacked divider), so the observed 4-red result was coherent but broader than intended — and rather than accept a result that agreed with expectation, it was redone with a unique anchor isolating only the tab-map copy, predicting and observing exactly 2 red / 20 green. An over-broad mutation produces a red that looks like proof and isn't. Catching that when the wrong answer was already the expected one is the discipline this lane runs on.

Part of, correctly

TabbedForm needs a visibleWhen key on its own published FormSectionConfig — a different surface, outside this ruling's Clause-② — and WizardForm steps are not fieldTabs at all. Both stay open on #6237, which is why this is not a Fixes. Docs (content/docs, READMEs) for the newly declared key are outside the fence and stay open too; they will be recorded on the card at landing so they are not carried in anyone's memory.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 52 chunks) 3236.5 KB 3266.6 KB
Main entry chunk (gzip) 157.1 KB 350 KB
Entry file index-COpP2g0j.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) 11.89KB 4.50KB
app-shell (runtime-config.js) 20.61KB 7.35KB
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) 5.13KB 2.35KB
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) 507.69KB 114.99KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 173.10KB 47.96KB
fields (index.js) 238.89KB 60.02KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.95KB 10.97KB
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.55KB 0.62KB
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 (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.85KB 12.89KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 190.33KB 45.10KB
plugin-dashboard (index.js) 133.48KB 34.49KB
plugin-designer (index.js) 212.80KB 43.15KB
plugin-detail (index.js) 245.29KB 62.39KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 132.01KB 32.23KB
plugin-gantt (index.js) 165.16KB 40.33KB
plugin-grid (index.js) 201.66KB 54.57KB
plugin-kanban (index.js) 53.11KB 14.62KB
plugin-list (index.js) 112.86KB 27.54KB
plugin-map (index.js) 20.09KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 26.72KB 7.71KB
plugin-tree (index.js) 9.26KB 3.13KB
plugin-view (index.js) 85.87KB 21.12KB
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) 65.97KB 21.98KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 2.44KB 1.21KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 12.13KB 3.65KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 9.30KB 3.22KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
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 (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.72KB 2.24KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
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 (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
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

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