Repository navigation
fix(layout,app-shell): a hidden icon-mode group label is no focus stop and is not announced (objectui#12795) - #12856
Conversation
…p and is not announced (objectui#12795) In the collapsed icon sidebar the SidebarGroupLabel primitive hides a label with opacity-0 only, so a collapsible group's label button stayed in the tab order (Tab from the row above landed on it and Enter toggled a group the user could not see), and every label stayed in the accessibility tree. Every SidebarGroupLabel call site (NavigationRenderer, AppSchemaRenderer, SidebarNav, UnifiedSidebar) now adds a variant-scoped `invisible`, with `visibility` joining the transition list under the same variant so the fade-out is kept. The expanded sidebar and the mobile sheet are unchanged. An enumeration pin asserts every call site carries the classes. Claude-Session: https://claude.ai/code/session_016gcD8krj7QLd3njFMYsi1g Co-authored-by: Claude <noreply@anthropic.com>
…e icon-mode label pins (objectui#12795) Claude-Session: https://claude.ai/code/session_016gcD8krj7QLd3njFMYsi1g 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
|
席位核对 — ACCEPT · objectui#12795 · head
|
| icon mode | main 0812a19 |
this branch |
|---|---|---|
| Tab 1 from Page Authoring | the Workspace label button, at opacity 0 | the My Work row, with a visible focus ring |
| Enter at Tab 1 | Workspace closes; the user cannot see the group | My Work opens; Workspace stays open |
| hidden labels in the aria snapshot | 9 of 9 | 0 of 9 |
The expanded sidebar (CONTROL) reads the same on both: the Workspace label is a focus stop at Tab 2, and Enter toggles it.
Screenshots, read by the seat (never-merged claude/issue-12795-evidence at 301f5d4):
- before,
main, icon rail after one Tab: no focus ring anywhere, because focus sits on the invisible label; - after, icon rail: the focus ring on My Work, with its tooltip;
- expanded sidebar (CONTROL): the focus ring on the Workspace group label.
Sent to the maintainer at 2026-10-17T04:33Z.
Tests:
- Two behavioural files: 8 tests in layout and 4 in app-shell. They run through the real
SidebarProvider,SidebarandSidebarGroupLabel, with the labels' own classes compiled by the real Tailwind compiler.- Collapsed: the Tab walk skips every label, and every label computes
visibility: hiddenand is inaccessible. - CONTROL, expanded: the label is a focus stop, and Enter toggles its group.
- Collapsed: the Tab walk skips every label, and every label computes
- One enumeration pin over every
SidebarGroupLabelopening tag inpackages/andapps/source. It refuses an aliased import. - Six ablations, each red where it should be and restored to the HEAD blob. Ablation A turns the Tab walk itself red.
@object-ui/layoutin full: 370 tests passed.@object-ui/app-shellin 8 shards: 14434 tests passed, 0 failed.- Type-check, build, eslint (0 errors, no warning on a changed line), the i18n checks, changeset presence, line citations and control bytes: all exit 0.
Changesets: @object-ui/layout and @object-ui/app-shell, a patch each; user-facing prose.
CI: running at this record. Landing waits for green.
Rulings
-
The two non-focusable call sites (
AppSchemaRenderer,SidebarNav): A, as built. The claim allowed them "only where a label is focusable", and the binding text also says no hidden label stays in the accessibility tree. Both labels were announced while invisible, as measured. One rule at every call site keeps the enumeration pin free of an exception list. Both files are named on the claimed surface, and each gains two classes. -
The contract conflict: as dispatched. The relay is refused from the dev's container, so the seat opened the PR and relayed the report.
-
Per-file jsdom with a real Tailwind compile: A, as built. It is the only shape that reads the Tab order and the accessibility tree. Class-list pins alone could not show either. The cost is two files that opt into jsdom with a
matchMediastub, and the reason is written in each. -
The three acceptance notes stay notes:
- the console's unlayered
buttontransition, which keeps a collapsible label from fading; - a closed group has no keyboard path in icon mode;
- objectui#12744's pointer class now matters only during the fade.
Each is pre-existing or follows from the ruling, and none is filed.
- the console's unlayered
Implemented-by: cloud session session_016gcD8krj7QLd3njFMYsi1g, branch claude/issue-12795-icon-label-focus
Generated by Claude Code
Landing · head
|
Fixes #12795
Clause-②: no
What was wrong
In the collapsed (icon-mode) sidebar, the
SidebarGroupLabelprimitive hides a label withopacity-0and-mt-8only. The hidden label stays in the tab order when it is a button, and every hidden label stays in the accessibility tree.Measured on
main0812a19: Tab from the Page Authoring row lands on the Workspace group-label button at opacity 0. The focus ring is drawn on nothing, and Enter closes Workspace, a group the user cannot see.Which labels could take focus (mechanism assumption 1, measured): only
NavigationRenderer's collapsible group label, anasChilddisclosure button. The Recent label has a click handler but no tab stop. The Area, Starred, Favorites,AppSchemaRendererArea andSidebarNavlabels are plain divs. All of them were in the accessibility tree: 9 of 9 hidden showcase labels appear in the icon-mode aria snapshot onmain.The change
Every
SidebarGroupLabelcall site now also passesHIDDEN_ICON_LABEL_NOT_FOCUSABLE. It is a module-local constant beside objectui#12744'sHIDDEN_ICON_LABEL_NO_POINTER, under the primitive's own icon-mode variantgroup-data-[state=collapsed]:group-data-[collapsible=icon]:. It holds two classes:invisible(visibility: hidden): takes the label out of the tab order and the accessibility tree while the primitive hides it.transition-[margin,opacity,visibility]: under the same variant,visibilityjoins the primitive's transition list. The label stays visible while it fades out and is hidden when the fade ends. On expand the variant no longer applies, so the label is visible at once and fades in as before.Call sites, 7 opening tags in 4 files:
@object-ui/layoutNavigationRenderer: the collapsible group label and the Favorites label;@object-ui/layoutAppSchemaRenderer: the Area label (it also rendersSidebar collapsible="icon", assumption 3);@object-ui/layoutSidebarNav: the group labels (collapsible="icon"by default);@object-ui/app-shellUnifiedSidebar: the Area, Recent and Starred labels.Off icon mode nothing changes: the variant does not apply in the expanded sidebar or the mobile sheet.
packages/components/src/ui/**is untouched (AGENTS.md #7). No export, prop, type member or i18n key moves, and existingdata-testids stay.Why
invisible, and the fade (assumption 4, measured)A bare
invisiblewould cut the fade-out: measured in the browser, the Recent label goes hidden on the first frame while its opacity is still running from 1 to 0. Withvisibilityadded to the transition list under the variant, the opacity curve is the same as onmain(1.00 to 0.00 over about 200 ms),visibilitystaysvisiblethroughout, and it turnshiddenat about 205 to 220 ms. The fade-in on expand matchesmainframe for frame. AninertortabIndexroute would need the sidebar state in JavaScript at each call site; the class rides the primitive's own CSS condition.Deviation from the claim's surface
The claim allows
AppSchemaRenderer.tsxandSidebarNav.tsxonly where a label is focusable. Their labels are not focusable, but they were announced while invisible. The rulings require that no hidden label is in the accessibility tree and that an enumeration pin covers every call site, so both files carry the constant, with no exception list.Live readings
Setup: Chromium at 1280 x 800, light. Backend: objectstack
c1c0a90examples/app-showcase,objectstack dev --fresh. Console: Vite from each worktree,main0812a19and this branchd678720. Every group was opened in the expanded sidebar, then the sidebar was collapsed with Ctrl+B.Icon mode, focus on Page Authoring, then Tab:
Expanded sidebar (CONTROL), the same on both:
Screenshots, from the evidence branch
claude/issue-12795-evidence, which is never merged here:main, icon rail after the same Tab, where focus sits on the invisible Workspace label: https://github.com/objectstack-ai/objectui/blob/301f5d44bc29a2a626a066fc9409f03d12810d39/docs/design/findings/12795/main-icon-rail-focus-on-hidden-label-light.png?raw=trueTests (at
d678720)packages/layout/src/__tests__/sidebarGroupLabel.iconModeFocus-12795.test.tsx(8 tests) andpackages/app-shell/src/layout/__tests__/UnifiedSidebar.iconModeLabelFocus-12795.test.tsx(4 tests). Both render through the realSidebarProvider/Sidebar/SidebarGroupLabeland the real renderers. The labels' own class tokens are compiled with the realtailwindcsscompiler and injected, so the DOM computesvisibilityfrom the classes the labels carry. The pins then read behaviour: user-event's Tab order and Testing Library's accessibility tree.visibility: hidden, keepsopacity: 0and is inaccessible. The expected tokens are derived from the primitive's ownopacity-0token, so a primitive that changes its hiding condition turns the pins red.:is(:where(.group)...)selectors Tailwind emits for group variants. They stubmatchMedia, which jsdom lacks.packages/layout/src/__tests__/sidebarGroupLabel.callSites-12795.test.ts(enumeration pin). It reads every opening tag ofSidebarGroupLabelinpackages/andapps/source (the syncedui/copy and tests aside) and asserts each passes the constant, declared in its own file with exactly the derived pair. It has a control that the known call sites are found, and it fails if a file renames the primitive on import.ablation-replace.mjs. Each mutation was confirmed on disk, and each restore was proven: the blob equalsHEADandgit diff HEADis empty.invisibletoken removed (themainshape)invisiblemade unconditionalinvisibletoken removedpnpm --filter @object-ui/layout test: exit 0, Test Files 43 passed (43), Tests 370 passed (370).@object-ui/app-shellin full, aspnpm exec vitest run packages/app-shell/ --shard=K/8for K = 1 to 8, run in sequence because an unsharded run exceeds the container's command limit. Every shard exited 0: 1528 files passed and 1 skipped, 14434 tests passed and 9 skipped, 0 failed.type-checkfor both packages: exit 0, and--listFilesshows each new test in its package's test program. The closure build andbuildfor both packages: exit 0. eslint on the 7 touched files: 0 errors, and every warning sits on an unchanged line.check:i18n-keys,check:i18n-drift,check-changeset-presence(2 changesets for 2 released packages),check:new-line-citationsandcheck-control-bytes: all exit 0.Acceptance notes
mainor here.apps/console/src/index.csssets an unlayeredbutton:not([disabled])transition (transform, filter) under@media (hover: hover), which outranks the primitive's layered transition. Div labels fade as designed. Not filed.main, Tab and Enter on the invisible label opened it by accident, which is the defect removed here. objectui#12744's PR notes the same for the pointer.visibility: hiddenalso stops hit-testing, so objectui#12744's pointer-events class now matters only during the roughly 200 ms fade-out. It is left in place.The work was done in session
https://claude.ai/code/session_016gcD8krj7QLd3njFMYsi1g.Generated by Claude Code