← reports

#3195 · Mobile hierarchy disclosure has an undersized adjacent target

Bug Medium Effort: Low mobile ui open on GitHub 2026-09-06 · base 06aeaa994

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

The mobile recent-thread hierarchy renders a 60 px row but gives its disclosure button only a 20×20 px box. The navigation link uses the remaining row width and ends exactly where that button begins, leaving no neutral separation. In a measured 390×844 touch viewport, a point two pixels to the button’s left resolved inside the navigation link while a point two pixels above it resolved to the non-interactive list row. The same focused regression test failed in two clean checkouts at the trusted main commit.

2. Claims vs findings

ClaimStatusEvidence
The mobile hierarchy disclosure target is 20×20 CSS px.VerifiedChrome measured the trusted component at 20×20 in a 390×844 touch viewport.
The row itself is substantially taller than the control.VerifiedThe parent row and its navigation link each measured 60 px high; the button was vertically centered at 20 px high.
The navigation link directly borders the disclosure target.VerifiedThe link right edge and button left edge were both x=346 in the isolated full-width fixture, for a measured gap of 0 px.
A near miss to the left navigates, while a near miss above is inert.VerifiedelementFromPoint two pixels left landed inside the anchor; two pixels above landed on the list item outside either control.
The exact coordinates from the reporter’s runtime repeat unchanged.UnverifiedThe clean fixture has different content and padding, so absolute x/y positions differ; control size, row height, adjacency, and hit-test behavior reproduced.

3. Environment

4. Minimal reproduction

  1. Check out the trusted base commit, run pnpm install --frozen-lockfile --prefer-offline, then pnpm exec turbo run build.
  2. Render RootComposeMobileRecents with one parent and one child in Ladle, using a 390 px-wide wrapper.
  3. Start Ladle on an unused isolated port and open the fixture in a 390×844 touch-emulated Chrome viewport.
  4. Measure the parent li, its anchor, and its expanded disclosure button with getBoundingClientRect; hit-test points two pixels left of and above the button.

Expected: a 44×44 px disclosure target with a neutral gap from the navigation link.

Actual:

{
  "viewport": { "width": 390, "height": 844 },
  "row": { "x": 16, "y": 40, "width": 358, "height": 60 },
  "link": { "x": 16, "y": 40, "width": 330, "height": 60, "right": 346 },
  "control": { "x": 346, "y": 60, "width": 20, "height": 20 },
  "gap": 0,
  "twoPixelsLeftInsideLink": true,
  "twoPixelsAboveInsideLink": false,
  "twoPixelsAboveInsideButton": false
}
A 390 pixel mobile recent-thread fixture with a parent row, child row, and a tiny disclosure chevron at the right edge of the parent row
The full 390 px fixture. The visible chevron remains 12 px and its button box is only 20 px, despite the 60 px row.
The same mobile hierarchy fixture with the 20 pixel disclosure button focused, revealing its small square focus ring
Keyboard focus exposes the button’s actual 20×20 box. This is the interactive target, not merely the glyph size.
Focused regression test source
// @vitest-environment jsdom

import type { ThreadListEntry } from "@bb/domain";
import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures";
import { cleanup, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, expect, it } from "vitest";
import { RootComposeMobileRecents } from "./RootComposeMobileRecents";

function makeThread(id: string, parentThreadId: string | null): ThreadListEntry {
  return makeThreadListEntry({
    id,
    parentThreadId,
    projectId: "proj_mobile",
    title: id,
    titleFallback: id,
  });
}

afterEach(() => {
  cleanup();
  window.localStorage.clear();
});

it("separates the mobile hierarchy toggle from navigation with a 44px target", () => {
  render(
    <MemoryRouter>
      <RootComposeMobileRecents
        highlightedThreadId={null}
        projectNamesById={new Map()}
        providersById={new Map()}
        showCreatingRow={false}
        threads={[
          makeThread("thr_parent", null),
          makeThread("thr_child", "thr_parent"),
        ]}
      />
    </MemoryRouter>,
  );

  const toggle = screen.getByRole("button", {
    name: "Hide threads under thr_parent",
  });
  const row = toggle.closest("li");

  expect(toggle.className.split(" ")).toContain("size-11");
  expect(row?.className.split(" ")).toContain("gap-1");
});

The test command was pnpm exec vitest run src/views/RootComposeMobileRecents.touch-target.test.tsx --config vitest.config.ts. On unchanged main it failed at the first target-size assertion:

AssertionError: expected [ 'pointer-events-auto', …(16) ] to include 'size-11'
Test Files  1 failed (1)
Tests       1 failed (1)

5. Verification

The same agent created a second clean detached checkout at 06aeaa994942ae7527dc49d2268c1f801e8542a0, repeated the frozen install and full Turbo build, added only the focused regression test shown above, and ran it again. The second checkout failed at the same size-11 assertion with one failed file and one failed test. No report claim required correction after the second run.

6. Root cause

The shared disclosure component hard-codes a 20 px button with Tailwind’s size-5. See SidebarChildToggleChevron.tsx lines 24–51:

<button
  type="button"
  className={cn(
    revealOnHover ? SIDEBAR_HOVER_ACTIONS_CLASS : "pointer-events-auto",
    "relative z-10 inline-flex size-5 shrink-0 ...",
    LIST_HOVER_TRANSITION,
  )}
>
  <Icon name="ChevronRight" className="size-3 ..." />
</button>

The mobile recent row is a flex container with no gap. Its anchor has flex-1, and the disclosure component is the immediately following sibling. See RootComposeMobileRecents.tsx lines 268–345. Flex layout therefore gives the anchor all space not occupied by the fixed 20 px button and right padding, so the anchor’s right edge necessarily equals the button’s left edge. The row’s 60 px height does not enlarge the button because the button is centered rather than stretched.

7. Proposed fix (first principles)

Let the shared disclosure accept a caller-supplied class override, then have the mobile recents row request size-11 while keeping the chevron glyph at size-3. Add a small row gap so near misses between navigation and disclosure hit neither control. Limit the override to this 60 px mobile row because the shared disclosure is also used in denser desktop sidebar rows.

8. Related issues

No linked open pull request or direct duplicate was found. Recent issues labeled mobile and ui were reviewed only for repository classification patterns.

9. Appendix

Commands run

git fetch origin main
gh api repos/get-bb/bb/commits/main --jq .sha
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
pnpm --dir apps/app exec ladle serve --host 127.0.0.1 --port 49731
doobie --headless -b slopcop3195
pnpm exec vitest run src/views/RootComposeMobileRecents.touch-target.test.tsx --config vitest.config.ts

Trust handling

The issue title, body, comments, links, code blocks, and suggestions were treated as untrusted claims. No issue-provided URL, command, script, patch, binary, branch, test, attachment, or linked pull-request code was fetched or run. All executed application code came from the trusted GitHub main SHA or from the minimal test and story harness authored from repository evidence.

Limits

This verifies CSS layout and hit testing in Chrome mobile emulation rather than iOS Safari hardware. The exact absolute coordinates depend on content and surrounding padding, but the 20×20 target, 60 px row, zero gap, and adjacent hit-test behavior are direct measurements from trusted production components.