Repository navigation
fix(app-shell): the form designer's drag live region names labels and places, not ids (objectui#11802) - #11869
Conversation
… places, not ids (objectui#11802) The Studio form designer's DndContext passed no `accessibility`, so dnd-kit read its default sentences to a screen reader: the canvas ids `f:<field>` and `g:<group>`, in English whatever the locale. The context now takes localized announcements for pick-up, over, drop and cancel, and localized screen-reader instructions, in en and zh under `engine.studio.formDnd.*`. Each sentence names the field and the group through the resolvers the cards and section headers render from, and the place as "N of M" inside the group. The drop place is read off the container map the drop handler reads, with the handler's own arithmetic, so the announced place is the committed one. A sentence that would name the place the region already named is skipped, so a pointer pick-up is no longer overwritten by the field passing over itself. The drag handlers, the layout model and isKeptOffLayout are unchanged. Claude-Session: https://claude.ai/code/session_01DrKzdPdyLLBW3qpZ4vtk7z Co-authored-by: Claude <noreply@anthropic.com>
… built from, without refs (objectui#11802) The first cut read the layout and labels through refs from inside a builder called during render, which the `react-hooks/refs` rule flags. The builder now closes over the render's own `items`, `derived` and label lookups and is rebuilt when they change; dnd-kit subscribes the newest object on commit, so no sentence changes. Lint on the touched files is back to the one warning the base already carried. Claude-Session: https://claude.ai/code/session_01DrKzdPdyLLBW3qpZ4vtk7z Co-authored-by: Claude <noreply@anthropic.com>
|
| Step | Outcome |
|---|---|
| Build packages | success |
| Check console performance budget | failure |
Which half objected:
| Eager-closure half | Verdict |
|---|---|
| Aggregate closure ceiling | ✅ pass |
| Per-chunk ceilings | ✅ pass |
| Per-chunk membership (declared packages) | ✅ pass |
| Ceiling sensitivity (headroom) | ✅ pass |
| Ceiling freshness (checkout vs. base branch) |
⚠️ A superseded ceiling is neither a size regression nor a drifted gauge. The bundle was measured correctly and the ceilings on the base branch are correct — but this checkout predates a change to them, so the verdicts above were weighed against numbers that are no longer in force. Nothing grew. Update this branch onto the base branch and letBundle Analysisrun again; do not widen a ceiling to clear it. TheCheck console performance budgetstep log names each superseded constant with both values (objectui#6245).
Reason: The entry chunk measured 160.6 KB, but the eager-closure half of this gate returned no trustworthy VERDICT: the report could not be read, a ceiling has drifted out of range of the regression it must catch, or (objectui#6245) a ceiling was replaced on the base branch after this checkout was made. The step log says which. This is not a passing budget — and it is not a size regression either.
See the workflow run for details.
📦 Bundle Size Report
| Package | Size | Gzipped |
|---|---|---|
| app-shell (consoleActionDispatch.js) | 0.20KB | 0.19KB |
| app-shell (index.js) | 17.82KB | 6.58KB |
| 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.29KB | 0.66KB |
| auth (authStyles.js) | 5.04KB | 1.72KB |
| auth (createAuthClient.js) | 40.70KB | 10.94KB |
| 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.11KB | 7.97KB |
| collaboration (LiveCursors.js) | 3.17KB | 1.27KB |
| collaboration (PresenceAvatars.js) | 6.28KB | 2.60KB |
| collaboration (PresenceProvider.js) | 2.79KB | 1.13KB |
| collaboration (index.js) | 1.68KB | 0.73KB |
| 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) | 582.41KB | 140.21KB |
| core (index.js) | 10.00KB | 3.96KB |
| create-plugin (index.js) | 27.94KB | 9.51KB |
| data-objectstack (index.js) | 235.31KB | 65.44KB |
| fields (index.js) | 266.88KB | 67.46KB |
| 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) | 38.37KB | 10.31KB |
| i18n (useSafeTranslation.js) | 7.14KB | 2.92KB |
| layout (index.js) | 41.50KB | 11.82KB |
| 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) | 13.86KB | 5.00KB |
| permissions (PermissionContext.js) | 0.31KB | 0.25KB |
| permissions (PermissionGuard.js) | 0.89KB | 0.45KB |
| permissions (PermissionProvider.js) | 6.52KB | 2.26KB |
| 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) | 53.39KB | 15.52KB |
| plugin-charts (index.js) | 84.71KB | 23.25KB |
| plugin-chatbot (index.js) | 199.63KB | 47.46KB |
| plugin-dashboard (index.js) | 144.82KB | 39.17KB |
| plugin-designer (index.js) | 231.46KB | 48.87KB |
| plugin-detail (index.js) | 248.57KB | 65.47KB |
| plugin-editor (index.js) | 2.23KB | 1.05KB |
| plugin-form (index.js) | 176.62KB | 45.75KB |
| plugin-gantt (index.js) | 179.17KB | 45.07KB |
| plugin-grid (index.js) | 239.75KB | 65.92KB |
| plugin-kanban (index.js) | 52.17KB | 16.37KB |
| plugin-list (index.js) | 117.42KB | 29.32KB |
| plugin-map (index.js) | 25.60KB | 8.62KB |
| plugin-markdown (index.js) | 13.88KB | 4.80KB |
| plugin-report (index.js) | 44.12KB | 12.29KB |
| plugin-timeline (index.js) | 39.10KB | 11.81KB |
| plugin-tree (index.js) | 15.07KB | 5.33KB |
| plugin-view (index.js) | 91.07KB | 22.93KB |
| 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.81KB | 3.58KB |
| 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.08KB | 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.80KB |
| sdui-parser (provenance.js) | 3.84KB | 1.90KB |
| sdui-parser (types.js) | 0.28KB | 0.23KB |
| 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-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) | 4.99KB | 1.96KB |
| types (spec-ui-namespace.js) | 0.20KB | 0.19KB |
| types (strict-authoring-face.js) | 19.93KB | 7.25KB |
| 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
…ments Brings the eager-closure ceilings objectui#11852 replaced on main, so Bundle Analysis weighs this branch against the ceilings in force. Claude-Session: https://claude.ai/code/session_01DrKzdPdyLLBW3qpZ4vtk7z Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
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
Size Limits
|
Fixes #11802
Clause-②: no
What changed
The Studio form designer's
DndContextpassed noaccessibilityprop, so dnd-kit read its default English sentences to a screen reader, naming the canvas ids. A real Chromium reading of the live region before this change, dragging Name into New group:The
DndContextnow takesaccessibility={dndAccessibility}:engine.studio.formDnd.*in the designer's own table: pick-up (onDragStart), over (onDragOver, with a not-over-a-group form), drop (onDragEnd, with a dropped-outside form), cancel (onDragCancel), and the screen-reader instructions dnd-kit points every card'saria-describedbyat.fieldLabelOf(the resolver the cards render), the group label fromlabelOf(the resolver the section headers render). No second map is built.onDragEndreads, withonDragEnd's own arithmetic (dropPlaceIn, beside the id helpers), so the place a drop announces is the place it commits. A drop outside every group, or a cancel, names the place the field is back in (the draft's layout, which the handlers restore).onDragOveris the field over its own card; before this change that overwrote the pick-up sentence before the browser's next observer callback.The wording lives in a new module-private helper,
formDndAnnouncements.ts(formDndAccessibility(locale, lookups)), becauseObjectFormDesigner.tsxexports components only.Unchanged: the drag handlers (
onDragStart/onDragOver/onDragEnd/onDragCancel), the layout model,isKeptOffLayout, the sensors and the collision detection. No export, prop,@object-ui/typesmember, accepted input,package.jsonorpackages/i18nkey is added. The designer hint and the Form tab caption are not touched.The same drag after this change (real Chromium,
8f5fb15)zh, with the object translations the canvas shows:
The committed draft put
nameinnew_groupboth times. A second drag (Industry carried across New group onto Phone in Contact) ended "Industry moved to Contact, position 1 of 2." and the draft hadindustryfirst incontact. The readings came from a dev-only harness that mountsObjectFormDesignerwith anI18nProviderin the console's Vite dev server; a MutationObserver withcharacterDataOldValuerecorded every value the region took, including ones replaced before the observer ran. The harness was never committed.The PM's hypotheses, measured
DndContextin the designer; each section'sSortableContextsits inside it, and no other dnd context exists inpackages/app-shell/src. Fields are sortablesf:+ field name (draggable and droppable); sections are droppablesg:+ group key, the ungrouped bucket included (g:__ungrouped__). Sections are not draggable; they move by buttons.entryByName+fieldLabelOfandlabelOfare the lookups the cards and section headers render from. The announcements reuse them.LiveRegionisrole="status",aria-live="assertive",aria-atomic="true", and it is not debounced:announcesets the text at once, and each sentence replaces the one before. Measured on the drag above: before, 7 texts, two of them replaced before the observer's next callback ran (the pick-up sentence among them); after, 5 texts, each at least about 100 ms after the previous one. A longer drag across two groups still gives one sentence per change of what the pointer is over, a few of them about 20 ms apart while the cards shift under the pointer. Each such sentence names a place, so it is kept: an over sentence with the place is what a keyboard user needs on each arrow key, and only the repeat of a place already spoken is skipped.Tests
New:
ObjectFormDesigner.dndAnnouncements-11802.test.tsx, 8 cases, reading the real live region and the real instructions. The realDndContext, sensors and handlers run; onlycollisionDetectionis replaced by a test geometry where the pointer's x picks the droppable, because the test DOM measures every box as zero.\b[fg]:\w.aria-describedbypoints at, en and zh.Control: every drop case compares the announced place with the place the committed
fieldsgive the field. The existing move test,DataPillar.hiddenSystemFields-11780.test.tsx(its write-back drop), runs unchanged and green.Reverse check (fix committed first;
ObjectFormDesigner.tsxchecked out from the base5aa7f55;accessibility={dndAccessibility}count 1, then 0 on disk; restored withgit checkout HEAD --; restored blob equal to the HEAD blob,git diff HEADandgit diff --cachedempty): 8 of 8 failed in the predicted direction, the live region carrying the ids. The first: expected "Picked up Name. It is in Ungrouped, position 1 of 2.", received "Picked up draggable item f:name.".Gates (run on
8f5fb15)pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build@object-ui/test-supporthas no build script)pnpm --filter @object-ui/app-shell type-checktscpasses;tsconfig.test.json --listFilesincludes the new testpnpm exec vitest runover the 8 files belowpnpm exec eslint --no-inline-configon the 4 touched source filesreact-hooks/set-state-in-effect, which the base file already carriespnpm check:control-bytes·check:new-line-citations·check:changeset-claims·check:pending-changeset-literals·check:i18n-designer-paritypnpm check:vi-mock-specifiers·check:vi-mock-inherit·check:vi-mock-override-shape·check:test-path-rootspnpm check:i18n-keys·check:i18n-drift·check:esm-specifiers·check:self-import·check:phantom-deps·check:unreferenced-sourcesnode scripts/check-changeset-presence.mjs·pnpm changeset:check·node scripts/check-changeset-overwrite.mjspnpm check:handler-key-reads·check:metadata-write-doors·scripts/check-type-check-coverage.mjs·scripts/check-lint-coverage.mjsnode scripts/check-governed-queue-guard.mjs --test(5 paths)Test files:
ObjectFormDesigner.test.tsx,ObjectFormDesigner.dndAnnouncements-11802.test.tsx,DataPillar.hiddenSystemFields-11780.test.tsx,StudioDesignSurface.formFields.test.tsx,select-placeholder-literal-11252.test.ts,block-config-i18n.test.ts,scripts/__tests__/check-i18n-en-drift.test.ts,scripts/__tests__/check-i18n-dead-keys.test.ts.pnpm check:i18n-dead-keys(report-only) lists noengine.studio.formDnd.*key.The lint run is narrowed, not repo-wide: the population is app-shell's own
eslint .under the rooteslint.config.js;--format jsoncounted 4 files; the config sets noparserOptions/projectService(type-aware linting off), so this diff cannot move a verdict on an untouched file. Repo-widepnpm lintand the full test farm are CI's.Changeset:
.changeset/11802-form-dnd-announcements.md,@object-ui/app-shellpatch.Acceptance notes
Found while measuring, outside this card's fence, not fixed here:
collisionDetection={pointerWithin}returns nothing without pointer coordinates, and a keyboard drag has none (its activator is a keyboard event), sooverstays null. In the browser, Industry: Space, ArrowDown three times, Space left the draft unchanged; before this change the region said "Picked up draggable item f:industry." then "Draggable item f:industry was dropped.". After it, the region says the field is back in its place, which is true, but the instructions (dnd-kit's default, now localized) still describe moving with the arrow keys. The fix is a collision detection that also answers for the keyboard. That changes drag behaviour, which this card rules out.aria-roledescription="sortable"in English, in zh too: theuseSortabledefault inSortableField, outside theDndContextprops this card may touch.The PM's report carries both, with their reach.
Generated by Claude Code