Félix Malfait 8990334cad Make record chips open records natively on touch devices (#23424)
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. -->
2026-07-28 22:44:36 +02:00
2026-06-11 11:02:28 +02:00

Twenty logo

The #1 Open-Source CRM

Website · Documentation · Roadmap · Discord · Figma

Twenty banner


Why Twenty

Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves. Twenty is the CRM you build, ship, and version like the rest of your stack.

Learn more about why we built Twenty


Installation

Cloud

The fastest way to get started. Sign up at twenty.com and spin up a workspace in under a minute, with no infrastructure to manage and always up to date.

Build an app

Scaffold a new app with the Twenty CLI:

npx create-twenty-app my-app

Define objects, fields, and views as code:

import { defineObject, FieldType } from 'twenty-sdk/define';

export default defineObject({
  nameSingular: 'deal',
  namePlural: 'deals',
  labelSingular: 'Deal',
  labelPlural: 'Deals',
  fields: [
    { name: 'name', label: 'Name', type: FieldType.TEXT },
    { name: 'amount', label: 'Amount', type: FieldType.CURRENCY },
    { name: 'closeDate', label: 'Close Date', type: FieldType.DATE_TIME },
  ],
});

Then ship it to your workspace:

npx twenty app:publish --private

See the app development guide for objects, views, agents, and logic functions.

Self-hosting

Run Twenty on your own infrastructure with Docker Compose, or contribute locally via the local setup guide.



Everything you need

Twenty gives you the building blocks of a modern CRM (objects, views, workflows, and agents) and lets you extend them as code. Here's a tour of what's in the box.

Want to go deeper? Read the User Guide for product walkthroughs, or the Documentation for developer reference.

Create your apps

Learn more about apps in doc

Stay on top with version control

Learn more about version control in doc

All the tools you need to build anything

Learn more about primitives in doc

Customize your layouts

Learn more about layouts in doc

AI agents and chats

Learn more about AI in doc

Plus all the tools of a good CRM

Learn more about CRM features in doc


Stack

Thanks

Greptile      Sentry      Crowdin

Thanks to these amazing services that we use and recommend for code review (Greptile), catching bugs (Sentry) and translating (Crowdin).

Join the Community

Star the repo · Discord · Feature requests · Releases · X · LinkedIn · Crowdin · Contribute

S
Description
The open alternative to Salesforce, designed for AI.
Readme AGPL-3.0 1.4 GiB
Languages
TypeScript 79.6%
MDX 17.3%
JavaScript 2.7%
Python 0.2%
SCSS 0.1%