Migrate twenty ui to linaria (#18307)

## Migrate twenty-ui from Emotion to Linaria

Completes the migration of all `twenty-ui` components from Emotion
(runtime CSS-in-JS) to Linaria (zero-runtime, CSS extracted at build
time).

- Replaced `@emotion/styled` with `@linaria/react` across ~170 files
- Removed all Emotion dependencies from `twenty-ui`
- Introduced a CSS custom properties-based theme system:
`themeCssVariables` where every leaf is a `var(--t-xxx)` reference,
injected onto `document.documentElement` by
`ThemeCssVariableInjectorEffect`
- No more `theme` prop threading — styled components reference
`themeCssVariables.x.y` directly at build time
- Updated `twenty-front` consumers to remove `theme={theme}` prop
passing

**Before / After:**
```tsx
// Emotion
color: ${({ theme }) => theme.font.color.primary};
padding: ${({ theme }) => theme.spacing(4)};

// Linaria
color: ${themeCssVariables.font.color.primary};
padding: ${themeCssVariables.spacing[4]};
```

### Theme architecture

Two build-time utilities produce the theme system:

- **`buildThemeReferencingRootCssVariables`** — walks the theme object
and builds a nested mirror where every leaf is a `var(--t-xxx)` string
(evaluated at build time by wyw-in-js)
- **`prepareThemeForRootCssVariableInjection`** — walks the runtime
theme and collects flat `[--css-variable-name, value]` pairs, injected
onto `document.documentElement` by `ThemeCssVariableInjectorEffect`

Both share naming conventions (`camelToKebab`, `SPACING_VALUES`,
`formatSpacingKey`) and are unit tested.

### Spacing cleanup

Spacing scale now uses integers 0–32 (generated via loop), with `0.5`
and `1.5` as the only fractional exceptions. All other fractional
spacing usages (`0.25`, `0.75`, `1.25`, `2.5`, `3.5`) were replaced with
literal pixel values across ~20 twenty-front files.

### Framer Motion integration

Linaria doesn't support `styled(motion.div)` — wrapping a motion element
with `styled()` causes the component body to be stripped at build time.
Instead, we define the styled component first, then wrap it with
`motion.create()`:

```tsx
const StyledBarBase = styled.div`
  background-color: ${themeCssVariables.font.color.primary};
  height: 100%;
`;

const StyledBar = motion.create(StyledBarBase);
```

### Block interpolations

Linaria doesn't support interpolations that return multiple CSS
declarations (Linaria wraps the entire block in a single `var()`,
producing invalid CSS). These were split into individual property
interpolations:

```tsx
// Emotion — single interpolation returning multiple declarations
border-left: ${({ divider, theme }) => {
  const border = `1px solid ${theme.border.color.light}`;
  return divider ? `border-${divider}: ${border}` : '';
}}

// Linaria — one interpolation per property
border-left: ${({ divider }) =>
  divider === 'left' ? `1px solid ${themeCssVariables.border.color.light}` : 'none'};
border-right: ${({ divider }) =>
  divider === 'right' ? `1px solid ${themeCssVariables.border.color.light}` : 'none'};
```

### Dynamic styles via CSS variables

When a component needs to compute styles from multiple props with
complex branching logic (e.g. `Button` combining `variant`, `accent`,
`inverted`, `disabled`, `focus`, `position`), Linaria's prop
interpolations become unwieldy. In those cases we use a
`computeDynamicStyles` function that returns a `CSSProperties` object
injected via `style={}`, referenced from the static CSS with `var()`:

```tsx
const StyledButton = styled.button`
  background: var(--btn-bg);
  border-color: var(--btn-border-color);
  &:hover { background: var(--btn-hover-bg); }
`;

const dynamicStyles = useMemo(() => {
  const s = computeButtonDynamicStyles(variant, accent, ...);
  return { '--btn-bg': s.background, '--btn-hover-bg': s.hoverBackground } as CSSProperties;
}, [variant, accent, ...]);

return <StyledButton style={dynamicStyles} />;
```

### CSS var + unit concatenation

CSS custom properties can't be concatenated with unit suffixes directly
(`var(--x)px` is invalid). Values that need units use `calc()`:

```tsx
// Broken
transition: background ${themeCssVariables.animation.duration.instant}s ease;

// Fixed
transition: background calc(${themeCssVariables.animation.duration.instant} * 1s) ease;
```
This commit is contained in:
Charles Bochet
2026-03-01 15:13:42 +01:00
committed by GitHub
parent 159bb9d70a
commit 1db2a40961
211 changed files with 3682 additions and 2997 deletions
@@ -1,10 +1,13 @@
import styled from '@emotion/styled';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { ThemeContext, type ThemeType } from '@ui/theme';
const StyledLayout = styled.div<{
width?: number;
backgroundColor?: string | undefined;
height: number | 'fit-content';
theme: ThemeType;
}>`
background: ${({ theme, backgroundColor }) =>
backgroundColor ?? theme.background.primary};
@@ -38,12 +41,17 @@ export const ComponentStorybookLayout = ({
backgroundColor,
height,
children,
}: ComponentStorybookLayoutProps) => (
<StyledLayout
width={width}
backgroundColor={backgroundColor}
height={isDefined(height) ? height : 'fit-content'}
>
{children}
</StyledLayout>
);
}: ComponentStorybookLayoutProps) => {
const { theme } = useContext(ThemeContext);
return (
<StyledLayout
width={width}
backgroundColor={backgroundColor}
height={isDefined(height) ? height : 'fit-content'}
theme={theme}
>
{children}
</StyledLayout>
);
};
@@ -1,15 +1,16 @@
import styled from '@emotion/styled';
import { styled } from '@linaria/react';
import { isNumber, isString } from '@sniptt/guards';
import { type Decorator } from '@storybook/react-vite';
import { type ComponentProps, type JSX } from 'react';
import { type ComponentProps, type JSX, useContext } from 'react';
import { ThemeContext, type ThemeType } from '@ui/theme';
const StyledColumnTitle = styled.h1`
const StyledColumnTitle = styled.h1<{ theme: ThemeType }>`
font-size: ${({ theme }) => theme.font.size.lg};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin: ${({ theme }) => theme.spacing(2)};
`;
const StyledRowsTitle = styled.h2`
const StyledRowsTitle = styled.h2<{ theme: ThemeType }>`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
@@ -17,7 +18,7 @@ const StyledRowsTitle = styled.h2`
width: 100px;
`;
const StyledRowTitle = styled.h3`
const StyledRowTitle = styled.h3<{ theme: ThemeType }>`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
@@ -25,7 +26,7 @@ const StyledRowTitle = styled.h3`
width: 100px;
`;
const StyledElementTitle = styled.span`
const StyledElementTitle = styled.span<{ theme: ThemeType }>`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
@@ -39,19 +40,19 @@ const StyledContainer = styled.div`
flex-direction: row;
`;
const StyledColumnContainer = styled.div`
const StyledColumnContainer = styled.div<{ theme: ThemeType }>`
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledRowsContainer = styled.div`
const StyledRowsContainer = styled.div<{ theme: ThemeType }>`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledRowContainer = styled.div`
const StyledRowContainer = styled.div<{ theme: ThemeType }>`
display: flex;
flex: 1;
flex-direction: row;
@@ -63,7 +64,7 @@ const StyledElementContainer = styled.div<{ width: number }>`
${({ width }) => width && `min-width: ${width}px;`}
`;
const StyledCellContainer = styled.div`
const StyledCellContainer = styled.div<{ theme: ThemeType }>`
align-items: center;
display: flex;
flex-direction: column;
@@ -97,6 +98,8 @@ export type CatalogOptions = {
};
export const CatalogDecorator: Decorator = (Story, context) => {
const { theme } = useContext(ThemeContext);
const {
catalog: { dimensions = [], options = {} } = {
dimensions: [],
@@ -118,27 +121,31 @@ export const CatalogDecorator: Decorator = (Story, context) => {
return (
<StyledContainer>
{dimension4.values.map((value4: any) => (
<StyledColumnContainer key={value4}>
<StyledColumnTitle>
<StyledColumnContainer key={value4} theme={theme}>
<StyledColumnTitle theme={theme}>
{dimension4.labels?.(value4) ??
(isStringOrNumber(value4) ? value4 : '')}
</StyledColumnTitle>
{dimension3.values.map((value3: any) => (
<StyledRowsContainer key={value3}>
<StyledRowsTitle>
<StyledRowsContainer key={value3} theme={theme}>
<StyledRowsTitle theme={theme}>
{dimension3.labels?.(value3) ??
(isStringOrNumber(value3) ? value3 : '')}
</StyledRowsTitle>
{dimension2.values.map((value2: any) => (
<StyledRowContainer key={value2}>
<StyledRowTitle>
<StyledRowContainer key={value2} theme={theme}>
<StyledRowTitle theme={theme}>
{dimension2.labels?.(value2) ??
(isStringOrNumber(value2) ? value2 : '')}
</StyledRowTitle>
{dimension1.values.map((value1: any) => {
return (
<StyledCellContainer key={value1} id={value1}>
<StyledElementTitle>
<StyledCellContainer
key={value1}
id={value1}
theme={theme}
>
<StyledElementTitle theme={theme}>
{dimension1.labels?.(value1) ??
(isStringOrNumber(value1) ? value1 : '')}
</StyledElementTitle>