8990334cad
Two mobile problems in the record table: tapping a chip in the first column takes two taps, and chips in every other column open a side panel where a full page is wanted. #23422 is stacked on this branch. ## Two taps to open a record The table's interactive layer lives in a hover portal mounted from `onMouseMove` on the table wrapper. Touch has no hover, so the browser fakes one, and the synthesised `mousemove` arrives *before* the `mousedown`. React commits the portal in the microtask between them, so the whole tap is hit-tested against a subtree that did not exist when the user aimed. Confirmed in Chromium with real touch input (`page.tap`, Pixel 5 emulation), mounting an overlay from the mousemove handler: ``` mousemove target=chip >>> overlay mounted <- the hover portal mousedown target=portalChip <- a node that did not exist when the finger went down mouseup target=portalChip click target=portalChip ``` The same test also ruled out `preventDefault` on the compat `mousedown` as a cause, and showed a `setTimeout`-deferred mount does *not* retarget — it is specifically React's sync flush timing that does. So hover state is now only tracked on hover-capable pointers. `useMoveHoverToCurrentCell` becomes the single writer and absorbs the deduplication `RecordTableContent` was duplicating inline. The interaction/layout split matters here: `useIsMobile` is a 768px width query, which answers "how much room is there to lay out", not "how does this person point". The new `useIsTouchDevice` uses `(hover: none) and (pointer: coarse)`. Layout keeps using width; interaction uses capability. ## Side panel on mobile "Where does a record open" was computed independently in six places and only `useOpenRecordFromIndexView` knew about mobile. `RecordChip` — every chip outside the first column, plus board cards and relation fields — had its own copy without that check. On mobile the side panel animates to `fullScreen`, so it is a full-page view with no URL and no back button. That decision now lives in one `resolveOpenRecordIn`: the view setting is an intent, and the side panel is only a real destination when there is room for it and the object supports it. Also here: `MOUSE_DOWN` navigation downgrades to `CLICK` on touch. It only buys a frame on a real pointer, since a tap synthesises its mouse events after the finger is already gone. ## Hover styling Separate layer, same root cause. A tap leaves CSS `:hover` applied until the next tap lands elsewhere, so a row you came back from keeps reading as selected. Nine `:hover` blocks across the record table, `Chip` and `Avatar` are now fenced behind `(hover: hover)` — the same media feature `useIsTouchDevice` branches on, via a new `hover-capable` SCSS mixin on the twenty-ui side and inline media queries in the Linaria components. Desktop rendering is unchanged, since Chrome matches `hover: hover`. Verified the built CSS emits the wrapper correctly, and checked the nested form through stylis directly for the Linaria side. ## Testing - New unit tests for `resolveOpenRecordIn` and for hover not being tracked on touch devices. - Full frontend suite: 951 suites, 5598 tests passing. Typecheck and lint clean. - Not observed end to end in a running app: no database in this environment, and the `RecordIndexPage` story renders an empty table under its msw mocks. The browser-level mechanism is verified and the fix removes the mid-gesture DOM change, but it is worth one pass on a real device before merge. ## Follow-ups not in this PR - The whole first cell navigates but only the chip-sized part of it gives tap feedback, and `isRecordTableRowActive` is only set on the side panel path — setting it on the navigate path too would keep the row lit while the page loads. - Rows are 32px against a 44px minimum touch target. - Giving the side panel a URL would make "panel vs page" a rendering decision on the same location, rather than something each call site has to branch on. --- _Generated by [Claude Code](https://claude.ai/code/session_019cDWPgWESbdRUhGxGb66j8)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23424?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->