twentyhq/twenty
Reviewed against Rams quality heuristics: accessibility, color, typography, spacing, components, motion, UX, and craft.
30 files reviewed·July 9, 2026
More findings
Verdict
Layout and hierarchy are consistently clean, but every edge case in the send flow renders blank or silently misbehaves. The biggest risk is a hotkey that can send a campaign the button itself refuses to send.
Files Rams reviewed
packages/twenty-front/src/modules/side-panel/pages/compose-campaign/components/SidePanelCampaignComposerPage.tsx
packages/twenty-front/src/modules/side-panel/pages/compose-email/components/SidePanelComposeEmailPage.tsx
packages/twenty-front/src/modules/side-panel/pages/front-component/components/SidePanelFrontComponentPage.tsx
packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelChartFilterSubPage.tsx
packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelFieldRelationTableFieldsSubPage.tsx
packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelFieldsLayoutSubPage.tsx
packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/SidePanelRecordTableFieldsSubPage.tsx
packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/SidePanelRecordTableFilterSubPage.tsx
packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/SidePanelRecordTableSortSubPage.tsx
packages/twenty-front/src/modules/side-panel/pages/record-page/components/SidePanelMergeRecordPage.tsx
packages/twenty-front/src/modules/side-panel/pages/record-page/components/SidePanelRecordPage.tsx
packages/twenty-front/src/modules/side-panel/pages/rich-text-page/components/SidePanelEditRichTextPage.tsx
packages/twenty-front/src/modules/side-panel/pages/search/components/SidePanelSearchRecordsPage.tsx
packages/twenty-front/src/pages/settings/applications/components/SettingsAppModalLayout.tsx
packages/twenty-front/src/modules/side-panel/pages/ai-chat-threads/components/SidePanelAiChatThreadsPage.tsx
packages/twenty-front/src/modules/side-panel/pages/ask-ai/components/SidePanelAskAiPage.tsx
packages/twenty-front/src/modules/app/components/App.tsx
packages/twenty-front/src/modules/app/components/DomainShell.tsx
packages/twenty-front/src/modules/app/components/I18nActivationGate.tsx
packages/twenty-front/src/modules/app/components/RootAppProviders.tsx
packages/twenty-front/src/modules/app/components/WorkspaceAppProviders.tsx
packages/twenty-front/src/modules/side-panel/pages/common/components/SidePanelSubPageNavigationHeader.tsx
packages/twenty-front/src/modules/side-panel/pages/page-layout/components/CanvasTabSettingsContent.tsx
packages/twenty-front/src/modules/side-panel/pages/page-layout/components/ChartFiltersDeletedFieldsWarning.tsx
packages/twenty-front/src/modules/side-panel/pages/page-layout/components/ChartFiltersSettings.tsx
packages/twenty-front/src/modules/side-panel/pages/page-layout/components/ChartFiltersSettingsInitializeStateEffect.tsx
packages/twenty-front/src/modules/side-panel/pages/page-layout/components/ChartLimitInfoBanner.tsx
packages/twenty-front/src/modules/side-panel/pages/page-layout/components/ChartSettings.tsx
packages/twenty-front/src/modules/side-panel/pages/page-layout/components/ChartTypeSelectionSection.tsx
packages/twenty-front/src/modules/side-panel/pages/page-layout/components/NewFieldDefaultVisibilityToggle.tsx
UX
Send campaign button gives no feedback while the request is in flight
The 'Send campaign' Button (lines 54-64) toggles only on `disabled={!campaignState.canSend}`. There is no in-flight/loading state passed to the button, so nothing on screen changes between the click and the panel closing.
Why it matters
Without a visible pending state, users who don't see an instant response click again or hit the hotkey again, risking a duplicate send request during network latency.
Fix
Disable the button and show a loading indicator for the duration of the async send call.
<Button
key="send"
size="small"
variant="primary"
accent="blue"
title={t`Send campaign`}
Icon={IconSend}
hotkeys={[getOsControlSymbol(), '⏎']}
onClick={campaignState.handleSend}
disabled={!campaignState.canSend}
/>,<Button
key="send"
size="small"
variant="primary"
accent="blue"
title={campaignState.isSending ? t`Sending…` : t`Send campaign`}
Icon={IconSend}
hotkeys={[getOsControlSymbol(), '⏎']}
onClick={campaignState.handleSend}
disabled={!campaignState.canSend || campaignState.isSending}
/>,Blank render when no widget is in edit mode confuses the panel
`SidePanelFieldsLayoutSubPage` returns `null` whenever `widgetInEditMode` is undefined: - No loading indicator while the widget resolves - No empty/error state if the user reaches this sub-page with nothing to edit Both the transient loading case and the truly-empty case render identically: a blank side panel.
Why it matters
A user opening this side panel sees nothing, with no signal whether the app is still loading or whether they hit a dead end. That reads as broken rather than deliberate, and it costs debugging time later when someone has to figure out which of the two states actually occurred.
Fix
Render a distinguishable loading state instead of silently returning null.
if (!isDefined(widgetInEditMode)) {
return null;
}if (!isDefined(widgetInEditMode)) {
return <StyledFieldsLayoutContainer aria-busy="true" />;
}Cancel discards a drafted email with zero confirmation
The "Cancel" button calls `onClick={goBackFromSidePanel}` directly, immediately navigating away and abandoning whatever the user typed into the composer fields.
Why it matters
A drafted email is destroyed silently the moment a user misclicks "Cancel", with no way to recover the lost content.
Fix
Confirm before discarding unsaved compose content, or route through the same guard used elsewhere for destructive navigation.
<Button
key="cancel"
size="small"
variant="secondary"
title={t`Cancel`}
onClick={goBackFromSidePanel}
/>,<Button
key="cancel"
size="small"
variant="secondary"
title={t`Cancel`}
onClick={() => {
if (composerState.hasUnsavedChanges) {
openDiscardDraftConfirmation(goBackFromSidePanel);
} else {
goBackFromSidePanel();
}
}}
/>,Lazy-loaded component has no visible loading state during fetch
The `<Suspense fallback={null}>` wraps the lazily imported `FrontComponentRenderer` but renders nothing while the chunk downloads.
Why it matters
Users see a blank side panel with no indication anything is happening, which reads as broken rather than loading, especially on slower connections where the JS chunk takes time to fetch.
Fix
Give Suspense a lightweight skeleton or spinner fallback instead of null.
<Suspense fallback={null}>
<FrontComponentRenderer
frontComponentId={viewableFrontComponentId}
selectedRecordIds={selectedRecordIds}
/>
</Suspense><Suspense fallback={<SidePanelFrontComponentSkeleton />}>
<FrontComponentRenderer
frontComponentId={viewableFrontComponentId}
selectedRecordIds={selectedRecordIds}
/>
</Suspense>Ctrl/Cmd+Enter can send a campaign even when the button says it can't
The 'Send campaign' button gates its click with `disabled={!campaignState.canSend}` (line 63), but the `useHotkeysOnFocusedElement` callback at line 35 calls `campaignState.handleSend` directly with no `canSend` check. The keyboard path and the mouse path enforce different rules for the same action.
Why it matters
A user who hits Ctrl+Enter while the form is incomplete can fire a send the UI visibly disabled, producing an unintended submit or a confusing failure with no on-screen explanation.
Fix
Gate the hotkey callback with the same canSend condition that disables the button.
useHotkeysOnFocusedElement({
keys: ['ctrl+Enter,meta+Enter'],
callback: campaignState.handleSend,
focusId: SIDE_PANEL_FOCUS_ID,
dependencies: [campaignState.handleSend],
});useHotkeysOnFocusedElement({
keys: ['ctrl+Enter,meta+Enter'],
callback: () => {
if (campaignState.canSend) {
campaignState.handleSend();
}
},
focusId: SIDE_PANEL_FOCUS_ID,
dependencies: [campaignState.handleSend, campaignState.canSend],
});Components
pageLayoutId skips the isDefined guard applied to its two siblings
`widgetInEditMode` and `viewId` both get an `isDefined` check before use, but `pageLayoutId` from `usePageLayoutIdFromContextStore()` is passed straight into `useWidgetInEditMode(pageLayoutId)` and down to `RecordTableSettingsFieldVisibility` with no equivalent check.
Why it matters
If the context store hasn't populated `pageLayoutId` yet, it flows into a hook and a child component that likely expect a real ID, producing an inconsistent guard pattern that's easy to miss when debugging a downstream failure.
Fix
Guard pageLayoutId with the same isDefined check used for the other two derived values before rendering the child.
const { pageLayoutId } = usePageLayoutIdFromContextStore();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);const { pageLayoutId } = usePageLayoutIdFromContextStore();
if (!isDefined(pageLayoutId)) {
return null;
}
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);Accessibility
Typography
Color
Spacing
Motion
Craft
Working well
- The `resolvedFieldsConfiguration` fallback (checking `configurationType === WidgetConfigurationType.FIELDS` before casting, otherwise using `temporaryFieldsConfiguration`) is a real discriminated-union narrowing rather than a blind `as any` cast, so a mismatched configuration type can't silently pass through as fields data.
- The footer correctly differentiates 'Cancel' (variant="secondary") from 'Send campaign' (variant="primary", accent="blue"): this is exactly right because the eye should land on the one action that commits the campaign, and a secondary treatment on Cancel keeps it available without competing for attention.
- The footer pairs a "secondary" variant Cancel button with a "primary" blue Send button. This is the correct pattern: the return-to-previous action stays visually quiet while the committing action (Send) carries the full weight, so users scan the footer and know instantly which button moves them forward.
- The Send button surfaces its keyboard shortcut inline via `hotkeys={[getOsControlSymbol(), '⏎']}`, and the same shortcut is wired through `useHotkeysOnFocusedElement`. Showing the shortcut next to the label it triggers is what makes power-user shortcuts discoverable instead of hidden trivia.
Score your own repo with Rams.
Free on public repos. Rams reviews every pull request for accessibility, color, type, spacing, components, motion, UX, and craft, and posts inline fixes.