Skip to content

fix(web): make remaining tooltip replacements readable and accessible

MacroscopeApp / Macroscope - UI Consistency failed Aug 16, 2026 in 6m 54s

UI Consistency: 1 issue found

Finding

  • apps/web/src/routes/_chat.pull-requests.tsx (~line 1645): the unavailability tooltip for disabled filter options is anchored on a new <span className="block"> wrapper placed between MenuRadioGroup (role="group") and its menuitemradio children. The wrapper exists only to work around MenuRadioItem's data-disabled:pointer-events-none, and it breaks the direct menu/item ownership. The repository already has a contract for a disabled menu item with a reason tooltip — data-disabled:pointer-events-auto on the item plus the tooltip trigger rendered onto the item, and the tooltip rendered only when a reason exists (components/RightPanelTabs.tsx:136-147, components/chat/ModelListRow.tsx:56-57).

Verified as clean

Previously reported hover-surface conflicts are resolved at this head: the DiffPanel trigger no longer wraps the base-ref Combobox, PullRequestBaseFreshnessWarning and AccessScopeSummary are outside their neighbouring tooltip triggers, and the DiffPanel remote-only indicator now labels its icon (role="img" aria-label="Remote only").

Details

Note

Your check run agent prompt is: .macroscope/check-run-agents/ui-consistency.md
More information about how Check Run Agents work can be found in our Help Center.

Methodology

  • Compared the full merge-base → head diff, then read the head revision of every touched in-scope file to check the final composition rather than the patch fragments.
  • Checked the shared primitive contracts used by the migration: components/ui/tooltip.tsx (positioner z-[140], portal, data-slot="tooltip-trigger"), components/ui/button.tsx (useRender/mergeProps, so render={<Button …/>} composes correctly), components/ui/toggle.tsx, and components/ui/menu.tsx (data-disabled:pointer-events-none on items, MenuPopup positioner z-[130], MenuPopup width-defaulting logic untouched).
  • Confirmed the migration follows existing repository patterns: Tooltip > TooltipTrigger render={MenuTrigger render={Button}} (preview/PreviewMoreMenu.tsx) matches the new ThemeColorPicker and DraftHeroHeadline triggers; icon-only triggers keep aria-labels; removed titles on controls that already expose visible text or aria-label (ChatView.tsx scroll-to-end pill) are not accessibility regressions; ConnectionStatusDot already owned a tooltip.
  • Checked disabled controls whose title was removed (BrowserDeviceToolbar aspect-ratio lock, ThreadPreviewMiniPlayer picture-in-picture): both use Button, whose base variant sets disabled:pointer-events-none, so the native title was already suppressed while disabled — no behavioral loss.
  • Verified all files using Tooltip* at head import it, so no missing-import breakage.
  • Reviewed the remaining title= occurrences in apps/web/src: they are props on custom components (SettingsRow, QRCodeSvg, Badge, MenuRadioItem) rather than intrinsic-element tooltips, so they are outside the new lint rule's intrinsic-only scope and outside this PR's changed lines; not reported.
  • ComposerPreviewAnnotationCards.test.tsx now counts data-slot="tooltip-trigger" occurrences (2 stats render for the fixture) and asserts the old title attributes are gone; that matches the component and is not flaky.

🔍 Agent activity — 1 tool call

💭 I'm checking what's changed since the last problematic commit by looking at the current diff. The DiffPanel component around line 585 has been updated to render the trigger with flexbox styling and proper width constraints. Looking through the prior issues: the Combobox is now positioned outside the trigger span in DiffPanel, the PullRequestDetailPanel has the freshness warning moved outside the trigger, ConnectionsSettings has the trigger properly nested, the chat pull-requests component wraps the MenuRadioItem correctly, and the CheckIcon for "Remote only" now includes proper accessibility attributes. All the previously flagged problems appear to be resolved. Now I need to examine the current code fresh for any new issues and understand how the TooltipTrigger component works semantically. Looking at the current diff to see what's changed. I need to check if ConnectionStatusDot.tsx and ChatView.tsx still have their tooltip attributes or if they've been removed without replacements — specifically whether there are any Tooltip components or aria-labels in place. The ChatView button and ConnectionStatusDot both have proper accessibility labels, so I can remove the redundant title attribute. Now I'm reviewing the previously flagged files to verify those fixes and look for any other issues, starting with DiffPanel around those line ranges. DiffPanel is working now. I'm noticing the TooltipTrigger nested inside ComboboxItem — when you hover the item, the tooltip opens, which is acceptable. The tooltip's z-index is higher than the combobox popup, so it renders on top as intended since it's anchored to the icon within the popup. Actually, I'm realizing Base UI's Tooltip.Trigger defaults to a button element, but when I pass a span as the render prop, it might be adding a tabindex. That's a rabbit hole I should avoid. The real issue is that Tooltip.Trigger opens on both hover and focus — for non-focusable spans, keyboard users can't trigger the tooltip. Though these are replacing title attributes on truncated text, which also weren't keyboard-accessible, so there's no actual regression here. Let me check PullRequestDetailPanel in those specific line ranges. I've resolved the earlier issues and now I'm looking at the pull requests component file to identify any remaining problems around line 1644. I'm wondering whether wrapping MenuRadioItem in a span element could cause issues with Base UI's MenuRadioGroup, since it relies on composite item registration through context and DOM-order navigation using refs. The framework computes item order based on DOM position, so adding a wrapper might interfere with that. There's also an accessibility concern: inserting a generic span between a group role and menuitemradio role could break the required owned-element relationship in the accessibility tree, since the group's children should be menu items directly. And there's a potential layout conflict too—the wrapper span is set to display block while MenuRadioItem is likely a flex row, which could cause styling issues. The tooltip trigger being a span rather than a button means keyboard users can't open it, since Base UI's Tooltip.Trigger only adds tabindex when rendering a button. For disabled menu items navigated via keyboard, the tooltip won't activate on focus since the menu moves DOM focus to the item itself rather than the trigger element. Actually, wait — focus events bubble through focusin, and Base UI's Tooltip.Trigger listens to onFocus which React maps to focusin, so focusing the inner menu item would propagate up and trigger the wrapper's focus handler. That should work. Now I'm checking the other modified files for any new issues. Looking at ComposerPreviewAnnotationCards and DraftHeroHeadline to see how they're using TooltipTrigger... In DraftHeroHeadline, there's a TooltipTrigger wrapping a MenuTrigger component with children passed through. The question is whether MenuTrigger properly accepts and merges children when passed via the render prop pattern — I need to verify how MenuTrigger handles the children and className merging from its parent TooltipTrigger. I'm noticing a potential issue with how the Tooltip and Menu interact — when the menu opens after clicking the trigger, the tooltip stays visible and overlaps with the menu popup. Base UI tooltips don't automatically close when a menu opens, which is a common pattern to handle but needs attention here. Looking at the specific implementation, the MenuTrigger sits inside an <h1> with inline text, and since TooltipTrigger uses a render prop to pass the MenuTrigger, it doesn't add an extra wrapper — the button element itself becomes the trigger, preserving the baseline alignment. For the inner tooltip at line 141, it wraps a span inside MenuRadioItem that previously had a title attribute, which is fine. Now I'm checking ProjectContentSearchDialog where a Toggle component is wrapped in TooltipTrigger using the render prop. Base UI's render prop accepts React elements and clones them with merged props, so the Toggle component needs to properly forward arbitrary props like className, onClick, data attributes, and refs to the underlying DOM element. Both approaches handle Base UI's render composition fine. Looking at ThemeColorPicker.tsx, I see a Tooltip wrapping a Popover wrapping a button — Base UI will merge the props onto the same element, which should work. The interaction feels a bit awkward though, with the tooltip staying visible above the popover when it opens on click. Let me check the other components like ServerUpdateAction, PullRequestChecksPopover, and ChangedFilesTree. Now scanning through several other files for similar patterns. In pullRequestPresentation.tsx, I notice PullRequestActorLabel is being changed from a span to a Tooltip — I need to verify it's not nested inside buttons or other interactive elements where that would cause issues. The component accepts a className prop, and since the Tooltip root doesn't render DOM, the trigger span should inherit the styling correctly. Let me trace through BrowserDeviceToolbar and ThreadPreviewMiniPlayer to see how the removal of title attributes on buttons interacts with the new TooltipTrigger setup. There's a subtle interaction with disabled buttons — when a button has disabled={pending || !customValid}, disabled HTML buttons don't fire pointer events in most browsers, so the tooltip won't actually display while the button is disabled. Previously the native title attribute also wouldn't show on disabled buttons, so the behavior should be consistent. So Base UI's Tooltip.Trigger relies on pointer and mouse events to detect when to open, but disabled buttons suppress those events entirely. And the button's base styles include pointer-events: none when disabled, which means the native title attribute also won't work—it's a complete loss of the tooltip functionality for disabled controls. Let me verify the other components where tooltips replaced title attributes. For ChangedFilesTree, the button already had visible text alongside the title, so the accessible name came from the content, not the title attribute. ServerUpdateAction and the QR code in ConnectionsSettings are fine too. Now checking the pull requests file... I need to check the ConnectionsSettings component around lines 680-720 to see how the paragraph wraps the Tooltip and whether formatExpiresInLabel is a sibling or nested inside. I'm noticing that wrapping MenuRadioItem in a block-level span changes how it behaves—the item becomes a block-level child that fills the width, whereas before it was a direct child of the radio group container. The key insight is that data-disabled:pointer-events-none on MenuRadioItem means when the item is disabled, pointer events pass through to the parent span, which is the tooltip trigger. This is exactly why the fix works—the tooltip opens when hovering a disabled item because the events target the span instead of the item itself. The z-index layering looks correct too: the tooltip at z-140 sits above the menu popup at z-130. Now I'm checking the ThreadPreviewMiniPlayer's resize handle to see if there are any issues with the tooltip opening during drag operations. Looking at the other components like ProjectSettingsPanel, FilePreviewPanel, and the various tabs, the Tooltip root renders no DOM so flex layouts stay intact. But I need to verify that all the files using Tooltip have actually imported it—the diff doesn't show import statements being added to several files that reference it. I'm checking that all the necessary imports are in place, then reviewing the tooltip conventions across the codebase—most existing tooltips use TooltipPopup without explicitly setting side="top" since that's the default anyway. Now I need to verify whether there are any remaining issues by looking more closely at specific new additions, starting with where PullRequestActorLabel in pullRequestPresentation.tsx gets used and whether it's nested inside a PullRequestRow. For PullRequestSummaryTab.tsx, the CollapsedComment component wraps a <p> element as the tooltip trigger, which is the same element that previously had a title attribute, so that's consistent. In ChangedFilesTree.tsx, the key is placed on the Tooltip root itself, which is fine. I should also verify the ThemeColorPicker structure around lines 438-470 to make sure the nested render props aren't creating any layout issues—previously the PopoverTrigger was rendered directly in a flex container, but now the Tooltip wraps it while rendering nothing itself, so that should be safe. I'm checking whether PullRequestActorLabel is nested inside a MenuCheckboxItem in PullRequestReviewerPicker, and then examining PullRequestRow to see if there's a conflict between having a tooltip on the actor label and another tooltip on the row itself when the row is a button element. I'm noticing that PullRequestActorLabel is now functioning as a Tooltip trigger, which gets used in a few different places—inside the button element of PullRequestRow, within PullRequestReviewerPicker as a potential MenuCheckboxItem, and in PullRequestReviewAnnotation. There's a concern here about whether the Tooltip trigger's pointer event handlers on the span might conflict with the row button's click behavior. Looking at the specifics: the tooltip closes on click without stopping propagation, which should be fine. The PullRequestReviewerPicker usage inside a menu item is also acceptable. And since PullRequestRow sits in a virtualized list with content-visibility: auto, the portals only render when tooltips are open, so that's not an issue either. Stepping back to evaluate whether this mechanical migration across 20 files introduces any real regressions—I'm checking a few key scenarios. The PullRequestActorLabel now always renders a Tooltip even when text isn't truncated, but that's not a functional problem. The "Discard draft" button in Sidebar.tsx uses pointer-events-none until the row hovers, so the tooltip behavior mirrors the previous title behavior. The sidebar "Woke" button tooltip also checks out. Moving through the remaining cases: the test assertions in ComposerPreviewAnnotationCards.test.tsx count tooltip-trigger data-slots, which is fine since the count should match. In BrowserDeviceToolbar, the Button's onPointerDown preventDefault shouldn't interfere with Tooltip.Trigger's pointer event handling. The ThreadPreviewMiniPlayer buttons use stopPropagation to prevent dragging when pressed—with the Tooltip.Trigger merged onto the same Button, both handlers still fire and stopPropagation continues to work as expected. There's something in Sidebar.tsx I need to look at more closely. Checking the breadcrumb structure in FilePreviewPanel to ensure the TooltipTrigger span doesn't create nesting issues with button elements, and verifying that flex properties are preserved correctly across the tooltip wrapper. I'm checking whether Base UI's Tooltip.Trigger renders a <button> by default and whether it automatically adds accessibility attributes like tabIndex or role. Since I've been explicitly providing render={<span/>} in the updated instances, I need to verify that the component handles the necessary a11y attributes properly. I'm also considering keyboard and touch interactions — native title attributes don't work on touch devices, and Base UI tooltips don't open on touch by default either, so that's a wash. The real constraint is that the oxlint rule I'm working with only applies to files in apps/web/src, not the plugin directory itself. Let me search for any remaining title= attributes on intrinsic elements in that scope that weren't migrated — if any exist, the lint rule set to "error" would fail the build. I'm noticing most of these are custom component props, but a few appear to be on intrinsic HTML elements like title attributes on images and SVG elements in the pull request and connections settings files. The title attribute on MenuRadioItem won't trigger the new lint rule since it only checks intrinsic elements, but the component forwards title to the DOM which creates a native tooltip. This creates a false negative — CompactFilterMenu in _chat.pull-requests.tsx was migrated to use Tooltip while its sibling component in PullRequestListFilters.tsx wasn't, leaving an inconsistency. This feels like a reportable finding since the PR's goal is to eliminate native title tooltips everywhere and enforce it with lint, yet it only migrated one of two identical menu implementations. The unchanged version still uses native title for the same PullRequestFilterOption type, making this a consistency gap the patch directly creates. Though I'm second-guessing whether this crosses into "unrelated repository-wide cleanup" territory — the guidance warns against demanding changes just because a file was touched. Let me check the other potential issues in files the PR actually modifies: ConnectionsSettings and PullRequestDetailPanel both have intrinsic elements that would fail the new lint rule at error level, which would be a concrete correctness problem introduced by the diff. Looking at the QRCodeSvg component, it's fine since it renders a title inside SVG, which the rule allows. But the Badge component in PullRequestDetailPanel is different — it's a custom component that forwards the title attribute to a native span, creating a native tooltip. Since this file is being modified by the PR and the Badge retains that native title, it falls within the scope of what should be addressed, though the guidance about touching large files suggests this might not be reportable as an unrelated issue. The real tension is that the lint rule and migration strategy leave native-title tooltips on component props like Badge, MenuRadioItem, and Toggle that forward to the DOM, so the enforcement is incomplete and creates inconsistency across the UI. The check instructions ask for concrete violations with precise inline comments rather than demanding unrelated cleanup, and since the oxlint rule file itself is out of scope, I should focus on actual regressions in the changed lines instead. Let me examine a couple of the remaining suspicious conversions, starting with how CompactFilterMenu wraps MenuRadioItem in a span. Now I'm checking whether wrapping MenuRadioItem in a generic span breaks the ARIA ownership relationship that Base UI's Menu expects—the role="group" on MenuRadioGroup should own menuitemradio children directly, but the extra span in between could cause screen readers to announce inconsistent item counts or other accessibility issues, though most implementations handle it through flattened traversal. I need to verify if Base UI actually requires MenuRadioItem to be a direct child. Let me search the codebase for existing patterns where tooltips wrap menu items to see if this is already handled elsewhere, like in ModelListRow. I'm checking whether the ComboboxItem component has pointer-events-none applied when disabled, since the existing tooltip pattern renders onto disabled items and might have the same accessibility issue. I'm noticing the PR took a different approach than what the codebase already established—instead of using the data-disabled pattern that keeps tooltips functional on disabled items, they wrapped the MenuRadioItem in a generic span, which breaks the existing convention and introduces unnecessary markup. I'm checking whether the wrapper span affects keyboard navigation or aria-activedescendant behavior in Base UI's Menu — it doesn't seem to cause issues. The real consistency point is that ModelListRow already uses the data-disabled:pointer-events-auto pattern safely, so the wrapper here is unnecessary. For the DiffPanel remote indicator, adding role="img" to the icon is actually the right call since lucide icons don't set aria-hidden by default, making the label more reliably exposed to assistive tech than other icon-only triggers in the codebase. Moving through the remaining findings: ConnectionsSettings has a harmless null check on the tooltip, ProjectSettingsPanel's copy button has a minor mismatch between tooltip and aria-label text, PullRequestDetailPanel combines two former titles into one tooltip which is reasonable, and ThemeColorPicker's tooltip might overlap the popover when open — I should check if the repo already handles this pattern elsewhere. Good, the pattern holds across all three components—Tooltip wrapping TooltipTrigger with MenuTrigger rendering Button. Now I need to verify the second Tooltip in ThreadPreviewMiniPlayer matches what the diff showed. I'm noticing a potential issue with the resize handle interaction—when handleResizePointerDown captures the pointer, the Base UI Tooltip that's anchored to the handle will stay visible and float over the preview area during dragging, which is a bit distracting compared to how the native title attribute would disappear. Let me trace through the pointer event flow: the tooltip trigger might have its own onPointerDown handler, but since mergeProps chains them together, setPointerCapture should still execute properly. The buttons inside the container already stop propagation, so the drag behavior should work fine. I should double-check the final implementations of the aspect-lock button in BrowserDeviceToolbar and verify how ChangedFilesTree uses the title attribute for accessibility on truncated file names. Looking at ServerUpdateAction, the tooltip wraps a truncated span with min-w-0 truncate preserved, which is correct. Now I need to verify that ComposerPreviewAnnotationCards.test.tsx expects exactly 2 tooltip triggers and confirm the component renders that exact count without any extras that could cause test flakiness. The test itself is working fine with the two stats being tracked, but I'm noticing we've lost some test coverage here. We're now asserting the count of tooltip triggers rather than validating the actual tooltip content itself. The tooltip text like "1 region" isn't being verified anymore since the popup doesn't render in the static markup, which means if the pluralization logic breaks in the future, the test wouldn't catch it. This is technically a coverage regression in an in-scope file, but it's tied to a visual migration rather than core logic changes. I could suggest asserting the tooltip text through the exported label logic, but that would require a client render instead of static markup, which isn't practical here. I'll skip flagging this since it's a weak finding and could come across as demanding tests for a visual-only change. The main issue I'm focusing on now is the wrapper span in the chat pull-requests file and whether it respects the established data-disabled:pointer-events-auto contract. Now I'm questioning whether this is really a failure-level issue or just a stylistic inconsistency. The wrapper does work functionally for the disabled tooltip case, so the main concern is whether it violates Base UI's menu structure expectations — specifically whether menu items need direct parentage under the group for proper ARIA and composite list indexing to work correctly. Let me dig into how Base UI's CompositeList actually handles item registration. It uses DOM position sorting rather than registration order, so wrapping in a span shouldn't break the index computation. I should search the repo for any existing patterns of menu items wrapped in intermediate elements to see if there's precedent for this approach. Looking at how they handle the disabled tab reason in RightPanelTabs — there's a TooltipPopup wrapping the reason text, so I need to check if they're using a similar approach with a wrapper span or pointer-events-auto to make it hoverable despite the disabled state. I see there's a direct precedent for this pattern—a disabled MenuItem that shows its unavailability reason through a tooltip, using data-disabled:pointer-events-auto to keep the item interactive and rendering the tooltip trigger directly on the item itself. The tooltip only appears for unavailable items, which is exactly what the PR's approach in _chat.pull-requests.tsx is trying to replicate with a wrapper span. I'm going to post an inline comment suggesting the existing pattern from the codebase, and I can provide a suggestion block that refactors lines 1644–1664 to follow that established convention instead of always rendering the tooltip root. Writing the replacement code... Refining the menu item structure... I'm working through the exact line numbers and indentation for the GitHub suggestion. The diff starts at line 1644, and I need to make sure the comment range covers the full code block being replaced—from the opening options.map through the closing brace. I'm verifying the indentation levels match what's in the file so the suggestion applies cleanly. Refining the conditional approach... I think the cleanest path is to keep the prose explanation and add a minimal unified diff that shows the key structural change—wrapping the disabled item with the tooltip trigger and keeping the reason comment intact. Now I'm noticing the Badge title= attribute at PullRequestDetailPanel:1414. The PR removes native title tooltips throughout, but this Badge still uses one, and since Badge is a component that forwards title to the DOM, the new lint rule won't catch it. It's the same concern the PR addresses, in a file the PR modifies, so it's worth flagging as a consistency gap the rule's intrinsic-only check leaves unaddressed. I'm weighing whether to flag this at all given that prior runs didn't catch it, and adding a new finding on unchanged code risks introducing noise. The wrapper itself isn't functionally broken—the tooltip still works, and the extra span with display:block doesn't cause layout issues even inside the menu group. I'm leaning toward skipping this one to stay consistent with how the check has behaved before. But actually, looking closer, there's a real semantic problem here: the wrapper inserts a generic non-menuitem element between the role="group" and role="menuitemradio", which breaks the established pattern of using DisabledReasonTooltip with data-disabled:pointer-events-auto. That's a meaningful ARIA structure change, so I should post the finding and mark it as a failure since the check defaults to that anyway. Let me verify the exact line for my comment — it's line 1645 with the TooltipTrigger and span element, which is in the diff. I also need to confirm that the data-disabled:pointer-events-none on MenuRadioItem gets properly overridden by the data-disabled:pointer-events-auto className since cn() merges the base class first, then the className, so twMerge resolves to auto. That checks out. Now I'll write the comment.

1. modify_pr — One consistency finding on the disabled-menu-item tooltip in _chat.pull-requests.tsx. The rest of the migration matches the existing tooltip contract (Tooltip > TooltipTrigger render={...} > TooltipPopup), keeps aria-labels, and the earlier hover-surface overlaps (DiffPanel combobox, PullRequestBaseFreshnessWarning, AccessScopeSummary) are resolved.


Agent Credits: 278 credits