Skip to content

fix(fields): name the boolean cell's read-only checkbox by its field (objectui#12584) - #12597

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-12584-boolean-checkbox-name
Oct 16, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-12584-boolean-checkbox-name

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #12584
Clause-②: no

What changed

The boolean cell renderer (BooleanCellRenderer in packages/fields/src/index.tsx) draws a plain read-only checkbox for every boolean column outside the completion and status names. That checkbox had no accessible name, so a screen reader announced each grid cell as a bare "checkbox, not checked", with no field. It is now named by the field:

  • One local value, fieldLabel: the field's label, else its humanized name. The off badge's text and the checkbox's aria-label both read it, so the expression is written once.
  • No state word in the name: the checkbox role announces checked or not checked itself. No new i18n key, no visual change (no class moved), no prop, export or type change. The Checkbox primitive is untouched.
  • The humanized fallback reads the field name as authored, not the lower-cased matching key (A3 below). Matching against the completion names and STATUS_FIELD_NAMES still uses the lower-cased key, unchanged.

Mechanism assumptions, measured

  • A1: the primitive forwards aria-label to the role element. Holds. Every naming pin finds its box with getByRole('checkbox', { name }), which reads the name off the element that carries role="checkbox" (Radix's button), not off a wrapper. The source agrees: the Checkbox wrapper spreads its props onto CheckboxPrimitive.Root.

  • A2: the grid hosts add no name of their own. Holds, read from both hosts' cell wrappers. The data-table renderer draws col.cell(...) inside a TableCell and a div that carries only className, title and data-auto-width-key. VirtualGrid wraps it in a div with a class only. Neither sets aria-labelledby or aria-label, so nothing is named twice. The card's CDP reading (9 unnamed checkbox nodes) is the runtime face of the same fact.

  • A3: fieldName is the lower-cased name. True. humanizeLabel only capitalises the first letter of each word, so the lower-cased key read an unlabelled mixed-case name wrong. Measured on humanizeLabel itself (packages/core/src/utils/humanize-label.ts):

    name humanized as authored (now) humanized lower-cased (before)
    IsPreferred IsPreferred Ispreferred
    is_preferred Is Preferred Is Preferred
    is_active Is Active Is Active
    IS_ACTIVE IS ACTIVE Is Active

    So the fallback now reads the name as authored, for the checkbox and the off badge alike, as the dispatch ruled. For every spec-legal name the words are byte-identical: the spec refuses an object field name that is not lowercase snake_case ("Field names must be lowercase snake_case"). The off badge's text moves only for an unlabelled, off-spec status name with an upper-case letter inside a word (for example IS_ACTIVE: "IS ACTIVE — Off", where it read "Is Active — Off"). humanizeLabel does not split camelCase, by its documented design, so IsPreferred reads IsPreferred, not "Is Preferred": the better of the two readings, not a perfect one.

Tests

New file packages/fields/src/__tests__/booleanCell.accessibleName-12584.test.tsx. It renders through getCellRenderer(resolveCellRendererType(...)), the way a grid host resolves a read-mode cell, for both boolean and toggle:

  • a labelled field, checked and unchecked: getByRole('checkbox', { name: 'Preferred' });
  • an unlabelled field: the humanized name, Is Preferred;
  • an unlabelled mixed-case name: IsPreferred (A3);
  • a status field that is on (is_active, true): the box named Active, the measured HotCRM cell;
  • CONTROL: the completion indicator keeps "Completed" / "Not completed" and draws no checkbox; the off badge keeps "Active — Off" / "Is Active — Off" and draws no checkbox; a non-boolean value draws the EmptyValue affordance ("No value") and no checkbox.

Ablation. The fix was committed first. The mutation went through ablation-replace.mjs, deleting aria-label={fieldLabel}: anchor 1 to 0, blob 8d8a77e75ac8 to 433e77c56102, on-disk count 0 inside the run. Result: Tests 10 failed | 3 passed (13). All 10 naming pins went red with "Unable to find an accessible element with the role "checkbox" and name ...", and the 3 CONTROLs stayed green. Restore proven: blob equals HEAD 8d8a77e75ac8, git diff HEAD empty. The test imports ../index relatively, so the subject resolves to source, not dist/, and no rebuild was needed for the mutation to reach it.

Runs, local, on a shared box, through the verify lock:

  • the new file: Tests 13 passed (13) at 63ba527469;
  • every packages/fields suite named for boolean (5 files): Tests 103 passed (103) at 63ba527469;
  • the whole @object-ui/fields suite: Test Files 257 passed | 1 skipped (258), Tests 4084 passed | 7 skipped (4091) at 789a28d784. The only later commit retypes the new test's render helper (as any to as never);
  • pnpm --filter @object-ui/fields type-check, after building its dependency closure (11 of 47 projects): exit 0 at 63ba527469. tsc -p tsconfig.test.json --listFiles lists the new test file;
  • the census and ratchet sweep, the suites whose subject this diff touches (aria-label literal census, icon-only button census, machine-locale census, ellipsis glyph, emptiness floor, humanizeLabel, grid cell face label, detail section, widget DOM-leak sweep, catalog gallery render): Test Files 10 passed (10), Tests 878 passed (878) at 789a28d784.

Gates

Local subset at the final head 63ba527469, each exit captured before any pipe: check:control-bytes 0, check:new-line-citations 0 (0 new citations), check-changeset-presence 0, check-changeset-no-major 0, check-changeset-claims 0, check-governed-queue-guard --test NOT GOVERNED. At 789a28d784, also 0: check-changeset-fixed, check-changeset-overwrite, check:pending-changeset-literals, check:i18n-keys, check:test-path-roots, the three check:vi-mock-*, check:comment-mask-corpus, check:shell-escape-residue, check-type-check-coverage. ESLint on the two changed source files: 0 errors, no warning in the changed lines of index.tsx, none in the new test. The full farm is CI's.

What the landing seat's browser reading should show

HotCRM, the Account list (the runtime list grid; the Studio Records grid draws the same renderer), the is_active column. In the browser's accessibility tree (CDP Accessibility.getFullAXTree), each row whose is_active is true is a checkbox node, checked, named by the field's label: "Active" under en (HotCRM declares label: 'Active'), the translated column label under another locale. None has an empty name, and no name carries "Yes", "No", "true" or "false". Rows whose is_active is false still draw the destructive "Active — Off" badge, unchanged, and no checkbox. Any other boolean column outside the completion and status names reads the same way: one named checkbox per row.

Acceptance notes

  • Observation, not filed (read from source, not reproduced in a browser): the data-table cell wrapper sets title to String(cellValue) for any non-object value, so hovering a boolean cell shows a raw true / false tooltip, in English under every locale. It does not name the checkbox: a title on an ancestor div is not part of the checkbox's name. Carrier: none.

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


Generated by Claude Code

The boolean cell renderer's plain-checkbox outcome drew a checkbox with no
accessible name, so a screen reader announced every grid boolean cell as a
bare "checkbox". It is now named by the field's label, else its humanized
name: one local value, also read by the status field's off badge. The
fallback humanizes the name as authored rather than the lower-cased matching
key.

Claude-Session: https://claude.ai/code/session_01JZnppgHRp4sREcouhkQYDB
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 70 chunks) 3092.1 KB 3119.4 KB
Main entry chunk (gzip) 73.3 KB 350 KB
Entry file index-BDlZGFD5.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) 21.19KB 7.76KB
app-shell (runtime-config.js) 22.59KB 7.89KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.62KB 0.83KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 41.19KB 11.12KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
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) 27.26KB 8.03KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (commentAuthorship.js) 1.39KB 0.78KB
collaboration (index.js) 1.93KB 0.85KB
collaboration (useCollaborationTranslation.js) 6.50KB 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) 596.60KB 143.79KB
core (index.js) 10.18KB 4.04KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 248.20KB 69.22KB
fields (index.js) 283.13KB 72.14KB
i18n (LocalizationContext.js) 2.92KB 1.42KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 36.87KB 9.88KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 40.67KB 11.85KB
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) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 15.35KB 5.51KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.82KB 2.38KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
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) 16.04KB 3.92KB
plugin-calendar (index.js) 54.94KB 16.05KB
plugin-charts (index.js) 84.97KB 23.36KB
plugin-chatbot (index.js) 201.89KB 48.13KB
plugin-dashboard (index.js) 145.69KB 39.39KB
plugin-designer (index.js) 233.95KB 50.08KB
plugin-detail (index.js) 262.91KB 69.49KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 180.88KB 46.69KB
plugin-gantt (index.js) 178.36KB 45.08KB
plugin-grid (index.js) 262.82KB 72.91KB
plugin-kanban (index.js) 53.23KB 16.71KB
plugin-list (index.js) 129.82KB 33.09KB
plugin-map (index.js) 27.07KB 8.96KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 46.66KB 13.13KB
plugin-timeline (index.js) 39.21KB 11.85KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 93.43KB 23.94KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 12.07KB 3.68KB
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) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.07KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.81KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
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) 1.13KB 0.65KB
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) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-approval-decision.js) 0.20KB 0.20KB
types (record-components.js) 0.20KB 0.19KB
types (record-panels.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) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.26KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.48KB 3.50KB
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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat ACCEPT: objectui PR #12597 at 63ba527469 · domain:ui seat 1 · session_01JZnppgHRp4sREcouhkQYDB · 2026-10-16T02:06Z

Verdict: ACCEPT. Reviewed against:

  • the card objectui#12584;
  • its triage grade 6204745162;
  • the claim 6205971429;
  • the dev report on the card.

No contract-review face applies:

  • Clause-②: no on line 2;

  • no type, prop, export or spec read is added;

  • none of the 3 files is a published-schema or governed path.

  • Shape: draft; base main; assignee sunhaolin. Line 1 is Fixes #12584, the body's only closing keyword. 3 files, +140/−2. One @object-ui/fields patch changeset, not major.

  • The fix:

    • BooleanCellRenderer derives the field's words once, as fieldLabel: its label, else its humanized name.
    • The off badge's text and the plain checkbox's aria-label both read it, so the expression exists once.
    • The name carries no state word, and there is no new i18n key, no class change and no change to the Checkbox primitive.
  • Pins: booleanCell.accessibleName-12584.test.tsx renders through the registry, as a grid host does, for both boolean and toggle:

    • It finds each box by getByRole('checkbox', { name }), so it reads the computed name of the Radix button that carries the role.
    • It covers a label, checked and unchecked; the humanized fallback; and a status field that is on (the measured HotCRM is_active cell).
    • CONTROLs: the completion indicator's two names, the off badge's text, and the non-boolean EmptyValue affordance.
  • Ablation: deleting aria-label={fieldLabel} turns the 10 naming pins red, and the 3 CONTROLs stay green. The restore is proven blob-identical to the head.

  • Mechanism assumptions, measured:

    • A1: aria-label reaches the element with the checkbox role.
    • A2: by source, neither the data-table cell wrapper nor VirtualGrid's cell names the control, so nothing is named twice.
    • A3: see the deviation below.
  • Gates: the dev ran type-check after a closure build, every boolean suite (103), the whole @object-ui/fields suite (4084 passed, 7 skipped), the census and ratchet sweep (878), the changeset gates, check:control-bytes, check:new-line-citations, check:i18n-keys and eslint, all exit 0. CI on 63ba527469: 37 runs, 34 success, 3 expected skips (dependabot and the coverage pair), 0 failures. mergeable_state is clean. The path face (get_files) is .changeset/, packages/fields/src/ and its __tests__/: no governed path, 142 lines.

  • Accepted deviation (A3, within the dispatch's conditional):

    • The humanized fallback now reads the field name as authored, not the lower-cased matching key, for the checkbox and the off badge alike.
    • For every spec-legal name, which is lowercase snake_case, the words are byte-identical.
    • Only an unlabelled, off-spec name with capitals moves. For example, IsPreferred now reads IsPreferred, where it read Ispreferred.
    • Matching (the completion names and STATUS_FIELD_NAMES) still uses the lower-cased key.
  • Out of scope: Acceptance notes only, ⛔ not filed. The data-table cell wrapper's title shows a raw true / false tooltip on hover under every locale. This was read from source and not reproduced, so its reach is unmeasured.

  • Browser reading: not taken before landing. The pin reads the same computed name the card measured through CDP. The PR's "What the landing seat's browser reading should show" section is kept for a post-landing check.

Landing: ready and auto-merge now. Its merge closes objectui#12584.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 16, 2026 02:07
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 16, 2026 02:07
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 16, 2026
Merged via the queue into main with commit 7d0c174 Oct 16, 2026
38 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-12584-boolean-checkbox-name branch October 16, 2026 02:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants