Félix Malfait 3f0236b590 fix(page-layout): let the column surface win over the solo presentation (#23412)
## Why

In the side panel and on mobile, a tab holding a single widget renders
with no gutter at all: the field list sits flush against the panel
border. Regression from #23109.

`getWidgetCardVariant` checked the derived presentation before the
surface:

```ts
if (presentation === 'solo') return 'solo';
const isSideColumnContext = isInPinnedTab || isMobile || isInSidePanel;
```

So a single-widget tab resolved to `'solo'` even in the side panel or on
mobile, and `'solo'` has no branch in `WidgetCard`'s padding switch, so
it falls through to `0`. The same widget used to match `variant ===
'side-column' && !isEditable` and get `spacing[3]` (12px).

The pinned left panel escaped this only because `PageLayoutLeftPanel`
hardcodes `presentation: 'stack'` — the rule was already there ("the
pinned left panel is always a column, a surface rule not a widget
rule"), just applied at one call site instead of being the rule.

## Why only the Home tab looks broken

Every widget that used to live on a `CANVAS` tab carries its own gutter,
so losing the card padding costs them nothing:

| Widget | Own horizontal padding |
|---|---|
| Timeline | `spacing[6]` |
| Notes | `spacing[6]` |
| Files | `spacing[6]` |
| Tasks | `spacing[6]` |
| **Fields** | **none** |

`Fields` was the only widget on a `VERTICAL_LIST` tab, so it was the
only one relying on the card for its gutter, and the only one that ends
up flush.

## What

Resolve the surface first: a column surface (pinned panel, side panel,
mobile) is always a column of cards, whatever the tab presentation is.
Solo stays a main-tab-area concept.

Header visibility is untouched: `showHeader` keys off `presentation`,
not the variant, so a solo tab still shows no bare title row. The
`Fields` widget does not regain the header it lost in #23109.

## Measured

Side panel, custom object whose Home tab holds a single Fields widget
(1600x1000, panel at x=1200):

| | Card padding | First label x |
|---|---|---|
| main | `0px` | 1221 |
| this PR | `12px` | 1233 |

12px restored, matching what the pinned left panel gives the same
widget.

## Trade-off worth a second opinion

In the side panel and on mobile, the activity widgets now resolve to
`'side-column'` instead of `'solo'`, so they pick up the card's 12px on
top of their own 24px, i.e. 36px instead of 24px. Nothing overlaps or
clips, but it is a visible change on those tabs. If you would rather
keep them at 24px, the follow-up is to drop the intrinsic `spacing[6]`
from the activity cards and let the surface own the gutter everywhere.

## Test plan

- `getWidgetCardVariant` tests extended: `'side-column'` now wins over
`'solo'` for each of `isInPinnedTab` / `isMobile` / `isInSidePanel`. 13
tests pass.
- 88 suites / 620 tests across `page-layout/widgets` pass.
- `lint:diff-with-main twenty-front` clean.
- Verified against a local stack: side panel on a single-widget Home
tab, before and after.
2026-07-28 13:47:07 +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%