Repository navigation
fix(mobile): always show hover-revealed controls on every touch device - #2009
Conversation
|
Warning Review limit reached
Next review available in: 55 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (19)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
2a16a0f to
64f285f
Compare
|
@coderabbitai review |
✅ Action performedReview finished.
|
Hover-revealed controls (AI chat retry/edit/copy/delete, page-tree row actions, tab close buttons, version-history and activity actions) were unreachable on every iPad — Capacitor app and iPad Safari alike. Three independent gates caused it, and no single naive fix catches all three: 1. Viewport-width gates (`sm:opacity-0 sm:group-hover:opacity-100`). These consult no pointer signal at all. An iPhone (~390px) never matches `sm:`, so the controls are permanently visible — which is why iPhone "just works". Every iPad matches, so they collapse behind a hover that never arrives. 2. Bare `group-hover:` with no touch hatch (~30 sites). 3. JS `isHovered` state gated on `useTouchDevice()`. `@media (hover: none)` is NOT a usable gate: the iOS app runs desktop-class (`preferredContentMode: 'recommended'`), so an iPad reports `hover: hover` and `pointer: fine`. The codebase already had a dead `[@media(hover:none)]` rule proving this. Detection is therefore JS-based on `navigator.maxTouchPoints` (iPadOS reports 5 even desktop-class; real macOS reports 0) — the same escape hatch useEnterToSend.ts already trusts. - New `lib/pointer-capability.ts` — one canonical `detectCoarsePointer()`, SSR-safe, unit-tested per branch. - `layout.tsx` stamps `data-pointer="coarse"` on <html> pre-paint via a nonce'd inline script, so there is no flash of hidden controls. The server emits no attribute, so desktop SSR markup is unchanged. - `globals.css` gains three unlayered rules covering all 41 hiding sites, plus a `touch:` custom variant for new code. - `data-hover-only` opts decoration out (screenshot scrim, drag seam, message timestamps) so phones don't regress. - `useTouchDevice()` now delegates to `detectCoarsePointer()`, which also revives `useChatPullToRefresh` on desktop-class iPad. Desktop is unchanged: no `data-pointer` attribute is ever emitted for a mouse-driven browser, so none of the new rules can match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BTxhiZAxjv9jcbVS8wJeUc
Addresses six findings from a high-effort review pass over the diff. - Keep the `[@media(hover:none)]:` classes alongside the new `touch:` variants instead of replacing them. The media query is dead on a desktop-class iPad, which is why `touch:` exists — but it covers iPhone and Android with zero JS. Keeping both degrades gracefully: if the inline script never runs, every device the media query already handled still works. Removing them traded a no-JS floor for nothing. - `detectCoarsePointer` now calls the existing `isCapacitorApp()` from lib/capacitor-bridge.ts rather than re-implementing it (and its `CapacitorGlobal` type) a fourth time. A future Capacitor rename would otherwise be fixed in capacitor-bridge and silently missed here, regressing detection to media-query-only — exactly this PR's bug. - Drop an unreachable `navigator` guard: inside a `typeof window` check it can never fire, and it made the function diverge in shape from the minified script it must mirror. - Cover `group-hover:flex|block|grid|inline-flex` display reveals, not just `inline`. These use exact class-token matching (`~=`), not substring: substring is correct for the opacity rules (it is what catches `sm:` and named-group variants) but here it would make `group-hover:flex` also match `group-hover:flex-row` and force the wrong `display`. - Add a parity test matrix pinning POINTER_CAPABILITY_SCRIPT to detectCoarsePointer across all eight device shapes. The hand-minified script is the module's one real maintenance hazard; desyncing them now fails the suite (verified by mutation). - Add useTouchDevice tests. Reverting `getSnapshot` to a bare `matchMedia('(pointer: coarse)')` — the obvious-looking simplification — previously passed every test while silently re-breaking every JS-hover affordance on desktop-class iPad. It now fails (verified by mutation). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BTxhiZAxjv9jcbVS8wJeUc
…ible delete An independent adversarial review of the mechanism found two real bugs the first pass missed, plus a latent landmine. - TabItem's `hidden group-hover:inline` span is the Cmd/Ctrl+N shortcut number, not an affordance — there is no keyboard to press on a phone. The display rule was pinning a stray digit into every tab, right next to the close button the same mechanism surfaces. Opted out. - Stop pinning hover fade-outs to `opacity: 0`. The prompt-input attachment thumbnail fades out on hover to expose its remove button; forcing that state on touch permanently hid the thumbnail on every phone, including iPhone where it renders fine today. We reveal controls; we do not simulate a whole hover state. Fade-out elements are now left at their natural resting opacity, with the revealed control sitting on top — so rule 2 is gone and rule 1 grew a `:not([class*='group-hover:opacity-0'])` guard. That guard also closes a landmine: adding any `:opacity-100` variant to a fade-out element would otherwise have dragged it into rule 1 and pinned a scrim permanently visible over the thing it covers. - FeedbackDialog's remove control is a full-bleed `<button>` that is invisible but still takes taps, so on touch the whole screenshot preview was a silent destructive target: tap to inspect it, delete it instead. Opting it out of the reveal (correct — it is a black scrim) left that intact. It now becomes a real corner delete badge on touch; desktop hover is unchanged. Verified the `touch:` overrides win the cascade against their base utilities in the compiled CSS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BTxhiZAxjv9jcbVS8wJeUc
… does Re-review caught the comment claiming the attachment thumbnail 'stays visible' beneath the pinned remove button. It does not: at a 20px chip the button's touch backdrop largely occludes it. The trade is still right — the filename beside it identifies the attachment, and a legible remove control beats a 20px preview — but the comment should say so rather than describe a behaviour the code doesn't have. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BTxhiZAxjv9jcbVS8wJeUc
…tches After opting out TabItem's keyboard hint, the display-reveal rules match nothing in the repo. Say so, and say why they stay: they are the standing policy so the next `hidden group-hover:flex` control is revealed on touch rather than silently disappearing — the bug class this file exists to close. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BTxhiZAxjv9jcbVS8wJeUc
The module header still claimed it replaced the `[@media(hover:none)]` rule. That was true of the first commit and stopped being true when the hardening pass restored those classes: both gates now ship together, the media query covering iPhone/Android with zero JS and this module covering the one device it cannot see. Also point the script's doc at the parity test that keeps it honest, since the hand-minified copy is this file's real maintenance hazard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BTxhiZAxjv9jcbVS8wJeUc
eefce8c to
f0231ec
Compare
Everything else about this feature was unit-tested, but the CSS selectors themselves — the blunt attribute-substring rules doing the actual work — were only ever verified by reading compiled output. Nothing failed if they stopped matching the controls they exist to reveal, or started matching decoration they must not touch. This reads the selectors OUT OF globals.css rather than restating them (which would only test a copy of itself) and runs them, in jsdom, against the real class strings from the components. It pins: - the controls that must be revealed, including the two viewport-gated cases that are the headline bug (MessageActionButtons' sm: gate, sidebar's md: gate) and the md: gate master re-introduced in #2006; - the decoration that must stay hidden, asserting each opt-out is load-bearing (revealed without data-hover-only, hidden with it); - that a hover fade-out is never pinned visible, including the landmine case where one later grows an :opacity-100 variant; - that exact-token matching keeps group-hover:flex-row from masquerading as a display reveal; - that a desktop device with no data-pointer stamp matches nothing at all. Verified the test can fail: dropping the group-hover:opacity-0 guard, making the display rules substring-matched, and removing the data-hover-only opt-out each fail it (1, 1 and 5 tests respectively). Writing it also caught a bug in its own harness — the first draft stripped the :where() ancestor from the extracted selector, which made the desktop assertion vacuous. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BTxhiZAxjv9jcbVS8wJeUc
Self-review of the [channelId]-keyed release found it can clobber a slot that now belongs to somebody else. The stop slot is a single shared singleton, and GlobalAssistantView writes it DIRECTLY from its own local status without going through the claim protocol. SidebarChatTab and GlobalAssistantView also track selectedAgent independently. So: sidebar claims the slot for agent B; the dashboard starts a stream on agent A and overwrites the slot; the user switches the sidebar's agent; our now-stale claim fires the release and nulls the DASHBOARD's live Stop button and its isAgentStreaming flag mid-stream — and its effect won't re-run to restore them. Re-keying the cleanup to [channelId] (the previous commit, and necessary) turned this from "only on unmount" into "on a routine agent-dropdown change", so it needed closing. Track the exact stop function we installed and release only when the slot still holds it. Applied to both release paths (the cleanup and onOwnStreamFinalize). Pinned by a test that fails without the check. Also merges origin/master: the sibling PRs #2009/#2010 landed, and master claimed migration slots 0200/0201, so the heartbeat migration was regenerated as 0202 (identical content). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ie3Bmn3sgiUJ7a6ErwEc4
…lose (#2049) * fix(development): proper machine-list states, test the mobile sheet close DevelopmentSidebar's machine list had one generic ListNotice string for every resting state. Replace it with the Machine page's shared SidebarLoading/SidebarNotice vocabulary (tab-states.tsx) so loading, empty, and failed states read like every other compact sidebar list in the app — and give the failed-load state a Retry action wired to SWR's mutate, in both drive-scoped and global (grouped-by-drive) mode. A drive with zero visible machines is dropped from the API payload by design (listMachinesAcrossDrives), so there's no reachable "empty drive group" state to build for global mode — noted inline rather than added as dead UI. Audited the mobile story end to end: the sidebar already inherits Layout.tsx's generic sheet treatment (it renders through MemoizedSidebar, which Layout puts in a Sheet below the app's mobile breakpoint), the Machine page's tab bar already went icon-only below `sm` in #2006 (before the Development surface existed, so it was inherited for free), and hover-revealed tree controls are covered by #2009's global touch-reveal CSS. The one real gap was test coverage: the isSheetBreakpoint-driven sheet-close-on-navigate wiring had no test. Added coverage for it, plus the new loading/error/retry state branches, in both modes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fzw6ieurR2FCLwbyT1wkRi * fix(development): wrap SWR mutate before wiring it to Retry's onClick Codex review on #2049 caught a real bug: SidebarNotice's Retry button uses onAction directly as the button's onClick, so React calls it with the click's MouseEvent. SWR's mutate() interprets a first argument as replacement cache data, not "revalidate now" -- so clicking Retry would have handed a MouseEvent to mutate() and corrupted the machines cache instead of refetching it, in both drive-scoped and global mode. Wrap both call sites in a genuinely no-arg callback (mirroring how DiffTab's own SWR-backed Retry already does this: void mutate(...) inside a useCallback). Strengthened both retry tests to assert the mutate spy was called with zero arguments, not just "called once" -- verified the new assertion actually fails against the pre-fix code before re-applying the fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fzw6ieurR2FCLwbyT1wkRi * fix(development): show loading, not a stale error, while a retry is in flight Self-review (6-angle finder pass) surfaced a real UX gap introduced by the Retry button added in the previous commit: resolveListNotice checked `hasError && isEmpty` ahead of `isLoading`, so clicking Retry gave zero visible feedback. Traced SWR's actual source (swr@2.4.1): isLoading is set back to true on any revalidation where cached data is still undefined -- exactly what a failed fetch leaves behind -- while `error` stays at its stale pre-retry value until the new attempt settles. Both are true at once mid-retry, so checking error first meant the same "Failed to load machines" text rendered throughout the retry, indistinguishable from the click doing nothing. Reordered: loading now wins over a stale error. Verified this doesn't regress the background-poll-must-not-blank-a-good-list case (that path has non-empty machines, so neither branch fires regardless of order) or the cold-load case (error is never set yet). Added a rerender-based regression test that simulates the actual retry-in-flight transition (not just a static prop snapshot) and confirmed it fails against the pre-fix ordering before restoring the fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fzw6ieurR2FCLwbyT1wkRi * fix(machine): fix the mutate-as-onClick bug at its root in tab-states.tsx Self-review (4-angle simplify pass) flagged that the previous commit's useCallback wrapper in DevelopmentSidebar.tsx fixed the "SWR mutate reads the click MouseEvent as replacement cache data" bug at only one of what turned out to be 7 call sites of SidebarNotice/PaneNotice's onAction (DiffTab, FilesFilePane, SettingsTab, MachineFileTree, FilesTab x2, and now DevelopmentSidebar). Every other call site already independently wrapped its callback in a zero-arg closure to dodge the same bug -- three hand-written copies of the same workaround, and nothing stops a future caller from reintroducing it: TypeScript structurally accepts SWR's mutate (or anything with an optional first parameter) wherever onAction: () => void is declared, so the mistake compiles clean. Fixed at the source instead: both SidebarNotice and PaneNotice now call onClick={() => onAction()} rather than onClick={onAction}, so every caller's zero-arg contract holds regardless of what onAction closes over. Verified safe for all 6 pre-existing call sites (their onAction callbacks were already effectively zero-arg) via the full consumer test suite (108 tests, all green). Simplified DevelopmentSidebar.tsx's retry wiring back down to passing SWR's mutate directly -- the local useCallback workaround is no longer needed. Added tab-states.test.tsx, direct coverage pinning the zero-arg guarantee at the component that now owns it, and confirmed by reverting the fix that both the new direct test and DevelopmentSidebar's retry tests fail without it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fzw6ieurR2FCLwbyT1wkRi --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
On iPad — both the Capacitor app and iPad Safari — every hover-revealed control was unreachable. There is no hover on a touchscreen, so AI chat message actions (retry / edit / copy / delete), page-tree row actions, sidebar affordances, tab close buttons, and version-history / activity actions were invisible, and those features were effectively gone.
This makes any touch device — iPhone or iPad, native app or mobile browser — always show them. Desktop is completely unchanged.
Why the obvious fix (
@media (hover: none)) is wrongThe iOS app sets
ios.preferredContentMode: 'recommended'(apps/ios/capacitor.config.ts), which gives the iPad desktop-class browsing: it reportshover: hoverandpointer: fine. A CSS-only hover/pointer gate is therefore dead on the exact device in the bug report.We don't have to take that on faith — the codebase already contained a dead one.
MessageHoverToolbar.tsx:89had[@media(hover:none)]:opacity-100, a touch hatch written for precisely this purpose, which never fires on a desktop-class iPad. This PR retargets it rather than deleting it.(pointer: coarse)alone fails for the same reason.(any-pointer: coarse)was rejected too — it would flip touchscreen Windows laptops and change desktop rendering, which is out of scope.So detection is JS-based, keyed on
navigator.maxTouchPoints: iPadOS reports 5 even in desktop-class mode, while real macOS reports 0. This is the same escape hatch the repo already trusts atuseEnterToSend.ts:26-29.Note this also means
preferredContentMode: 'mobile'would not have fixed it — thesm:/md:gates below consult no pointer signal at all, and it could never reach iPad Safari.The three independent gates
A single naive fix catches only one of them.
Gate 1 — viewport-width gates. This was the user's #1 complaint and the least obvious bug.
MessageActionButtons.tsx:26wassm:opacity-0 sm:group-hover:opacity-100. This is Tailwind v4, sosm:=min-width: 640px. An iPhone in portrait (~390px) never matches, so the buttons are permanently visible — which is exactly why iPhone "just works" today. Every iPad matches (≥744px, 1024px+ desktop-class), so they collapse toopacity-0behind a hover that will never arrive. This gate consults no pointer/hover/touch signal whatsoever, which is why it broke iPad Safari too.Gate 2 — bare
group-hover:with no touch hatch (~30 sites). Tailwind v4 wraps hover variants in@media (hover: hover), but the baseopacity-0always applies.Gate 3 — JS
isHoveredstate, gated onuseTouchDevice(), whose only signal was(pointer: coarse).The mechanism
apps/web/src/lib/pointer-capability.ts(new) — one canonical, pure, SSR-safedetectCoarsePointer(): Capacitor-native (any content mode) ORmatchMedia('(pointer: coarse)')OR (maxTouchPoints > 1AND an/iPad|Macintosh|iPhone/UA). Unit-tested per branch, including desktop-class iPad and a real-macOS negative.apps/web/src/app/layout.tsx— a nonce'd inline script, first child of<body>next to the existing__webpack_nonce__script, stampsdata-pointer="coarse"on<html>before first paint, so there is no flash of hidden controls.<html>already carriessuppressHydrationWarning.apps/web/src/app/globals.css— three unlayered rules (unlayered beats@layer utilitieswith no!important; same trick as the existingsvg.lucideoverride) driven off that attribute, plus a@custom-variant touchso new code has a first-class idiom.The selectors use attribute-substring matching, which is what catches both the named-group variants (
group-hover/msg:,group-hover/pane:,group-hover/item:,group-hover/menu-item:) and thesm:/md:-prefixed forms. A plain.group-hover\:opacity-100selector would have missed the user's #1 complaint entirely.pointer-events: autois required, not cosmetic:MessageHoverToolbar.tsx:88andprompt-input.tsx:329pair theiropacity-0withpointer-events-none. Opacity alone would leave the buttons visible but dead, which is arguably worse than hidden.Call-site inventory
All 41 hiding sites found by
grep -rn 'group-hover' apps/web/src. The diagnosis predicted ~36; the extra sites are noted below. They reconcile as:group-hover:)data-hover-only(decoration)Plus 4 JS-hover sites and 4 dead-media-query sites, below.
Gate 1 — viewport-width gated (9) — all covered by CSS
ai/shared/chat/MessageActionButtons.tsx:26sm:opacity-0 sm:group-hover:opacity-100ui/sidebar.tsx:569md:opacity-0+group-hover/menu-item:opacity-100right-sidebar/ai-assistant/SidebarActivityTab.tsx:505sm:opacity-0 sm:group-hover:right-sidebar/ai-assistant/SidebarActivityTab.tsx:582sm:opacity-0 sm:group-hover:right-sidebar/ai-assistant/SidebarActivityTab.tsx:636sm:opacity-0 sm:group-hover/item:version-history/VersionHistoryItem.tsx:173sm:opacity-0 sm:group-hover:activity/ActivityItem.tsx:147sm:opacity-0 sm:group-hover:activity/ActivityGroupItem.tsx:137sm:opacity-0 sm:group-hover:.../terminal/workspace/TerminalPanes.tsx:215md:opacity-0+md:group-hover/pane:opacity-100Gate 2 — bare
group-hover:(24) — all covered by CSSlayout/tabs/TabItem.tsx:201.../terminal/workspace/RemoveButton.tsx:15shared/MessageHoverToolbar.tsx:87group-hover/msg:+pointer-events-none— needspointer-events: autonotifications/NotificationItem.tsx:166inbox/DMCenterList.tsx:208inbox/ChannelsCenterList.tsx:229tasks/TaskKanbanComponents.tsx:138.../task-list/TaskKanbanView.tsx:176.../task-list/TaskKanbanView.tsx:225.../task-list/StatusConfigManager.tsx:242ai/ui/queue.tsx:129ai/ui/message.tsx:370ai/ui/message.tsx:398ai/ui/prompt-input.tsx:329group-hover:pointer-events-auto— needspointer-events: auto.../tool-calls/ActivityRenderer.tsx:194.../tool-calls/AgentListRenderer.tsx:113.../tool-calls/PageTreeRenderer.tsx:113.../tool-calls/SearchResultsRenderer.tsx:127.../tool-calls/AgentConfigRenderer.tsx:73.../tool-calls/WebSearchRenderer.tsx:133.../tool-calls/ActionResultRenderer.tsx:189.../tool-calls/DriveListRenderer.tsx:117.../tool-calls/calendar/CalendarEventRenderer.tsx:101.../tool-calls/workflow/WorkflowCard.tsx:118Opted out with
data-hover-only(6) — decoration, not affordancesShipping these together with the blanket rule is mandatory, or we'd introduce an iPhone regression — pinning genuinely-hover-only chrome permanently on-screen where it is fine today.
shared/FeedbackDialog.tsx:321bg-black/50scrim over the screenshot preview — would permanently black out the screenshot on every phone. (Still tappable to remove: it was neverpointer-events-none.)ui/resizable.tsx:54.../channel/ChannelView.tsx:705dashboard/dms/[conversationId]/page.tsx:624.../task-list/StatusConfigManager.tsx:238layout/tabs/TabItem.tsx:184Fade-outs are left alone, not pinned (deviation from the diagnosis)
The diagnosis prescribed a second rule forcing
group-hover:opacity-0elements (the prompt-input attachment thumbnail) toopacity: 0on touch. Shipping that pins the faded state permanently, so an iPhone user attaching an image would see a bare ✕ where their thumbnail renders fine today — a regression on a device that was never broken.We reveal controls; we do not simulate a whole hover state. So that rule is gone. Fade-out elements keep their natural resting opacity and the control they were hiding sits on top of them, rather than the element being force-hidden by a global rule.
For this one element the visible outcome is admittedly similar — at a 20px chip there is no room for both a legible ✕ and a readable thumbnail, so the ✕ gets a
touch:backdrop that largely occludes it (the filename beside it identifies the attachment). The difference is architectural and it matters: the behaviour is now local and explicit on the one element that needs it, instead of a global rule that force-hid every fade-out on the page and carried the landmine below.Rule 1 grew a
:not([class*='group-hover:opacity-0'])guard to make this safe, which also closes a landmine: adding any:opacity-100variant to a fade-out element (afocus-within:opacity-100, say) would otherwise have dragged it into rule 1 and pinned a scrim permanently visible over the content it covers.Deliberately revealed, though arguably decoration
Called out so it reads as a decision, not an oversight. These become permanently visible on touch: the disclosure
ChevronRighton inbox rows (DMCenterList.tsx:208,ChannelsCenterList.tsx:229), theExternalLinkicon on the ten tool-call renderer rows, and the "Open agent" hint onWorkflowCard.tsx:118. Each one signals this row opens something — which is the standard iOS disclosure convention and the only tappability cue left once hover is gone. Happy to opt any of them out on request.Not matched, no action (1)
ai/ui/inline-citation.tsx:53—group-hover:bg-accentis a colour change with noopacity-0, so no selector matches it. Correct: nothing is hidden.Gate 3 — JS
isHovered(4 + 2 fixed transitively)hooks/useTouchDevice.ts:10detectCoarsePointer()(keeps theuseSyncExternalStoreshape).../page-tree/PageTreeItem.tsx:419isTouchDevice || isHovered ? …left-sidebar/FavoritesSection.tsx:205isTouchDevice || isHovered ? …navbar/DriveSwitcher.tsx:307isTouchDevice || isHovered ? …left-sidebar/DriveList.tsx:97isTouchDevice || isHovered) — repaired by theuseTouchDevicechange alonehooks/useChatPullToRefresh.ts:62The dead
[@media(hover:none)]rules (4) — augmented, not replacedMy first pass swapped these to
touch:. That was wrong, and self-review caught it: the media query is dead only on a desktop-class iPad. On iPhone and Android it works with zero JS. Replacing it traded a no-JS floor for nothing.So both gates now ship together.
[@media(hover:none)]covers every no-hover device without JS;touch:adds the one device the media query cannot see. If the inline script never runs, everything the media query already handled still works.The spacing hacks matter: without them the now-visible toolbar overlaps message text.
shared/MessageHoverToolbar.tsx:89[@media(hover:none)]:opacity-100 …:pointer-events-auto, addstouch:opacity-100 touch:pointer-events-auto.../channel/ChannelView.tsx:710[@media(hover:none)]:pr-28, addstouch:pr-28.../thread/ThreadPanel.tsx:784[@media(hover:none)]:pr-28, addstouch:pr-28dashboard/dms/[conversationId]/page.tsx:630[@media(hover:none)]:pr-28, addstouch:pr-28Desktop is provably unchanged
grep -rn 'data-pointer' apps/web/srcreturns exactly three hits: two comments and the inline-script string. No JSX anywhere sets it, so SSR<html>markup is byte-identical to today for every UA.maxTouchPoints: 0, Macintosh UA) stamps nothing, and that a touchscreen Windows laptop stamps nothing either.[data-pointer='coarse']. With no attribute, none of them can match — so hover behaviour on a mouse-driven browser is untouched by construction.bun run build, not just the source: all three override rules are present with zero enclosing at-rule blocks (i.e. genuinely unlayered, so they beat@layer utilitieswithout!important), and Tailwind generated.touch\:opacity-100,.touch\:pointer-events-auto,.touch\:pr-28as:where([data-pointer=coarse] *).The CSS contract is tested, not just asserted
The selectors doing the actual work are blunt attribute-substring rules, and nothing used to fail if they stopped matching the controls they exist to reveal — or started matching decoration they must not touch.
app/__tests__/touch-reveal-rules.test.tsnow reads the selectors out ofglobals.css(rather than restating them, which would only test a copy of itself) and runs them in jsdom against the real class strings from the components. It pins the controls that must be revealed, the decoration that must stay hidden (asserting each opt-out is load-bearing — revealed withoutdata-hover-only, hidden with it), the fade-out landmine, exact-token display matching, and that a desktop device with no stamp matches nothing at all.I verified it can fail rather than assuming it would: dropping the
group-hover:opacity-0guard, making the display rules substring-matched, and removing thedata-hover-onlyopt-out each break it (1, 1 and 5 tests). Writing it also caught a bug in its own harness — the first draft stripped the:where()ancestor out of the extracted selector, which made the desktop assertion vacuous.Two guards, both mutation-tested
The module has one genuine maintenance hazard:
POINTER_CAPABILITY_SCRIPTis a hand-minified copy ofdetectCoarsePointer(), and it has to be, because it runs before any bundle loads. Two tests pin the things that would otherwise rot silently — and I verified each actually fails when the regression it guards is introduced, rather than trusting that it would:maxTouchPointsthreshold fails 2 tests.useTouchDevicedelegation — revertinggetSnapshotto a barematchMedia('(pointer: coarse)')(the obvious-looking simplification, andTOUCH_QUERYis still sitting in the file) previously passed the entire suite while silently re-breaking every JS-hover site on desktop-class iPad. It now fails 2 tests.Verification
tsc --noEmit(apps/web)next lint --dir srcnext buildtouch:variantvitest run(new tests)pointer-capability, 3useTouchDevice, 17touch-reveal-rules(CSS contract), 2 layoutvitest run src/lib src/hooksThe 16 failures are pre-existing and unrelated — I re-ran those three files against a clean stashed tree and got the identical 16 failures. They are DB-dependent (
role "test" does not exist) inauth/admin-role-version,ai/tools/activity-tools, andmessages/grouping. Nothing in them touches hover or pointer code.🤖 Generated with Claude Code
https://claude.ai/code/session_01BTxhiZAxjv9jcbVS8wJeUc