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,13 +1,15 @@
import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { styled } from '@linaria/react';
import { useIsMobile } from '@ui/utilities';
import { getOsShortcutSeparator } from '@ui/utilities/device/getOsShortcutSeparator';
import { type MotionProps, motion } from 'framer-motion';
import React, { useContext, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Pill } from '@ui/components/Pill/Pill';
import { ThemeContext, themeCssVariables } from '@ui/theme';
import {
type ButtonAccent,
type ButtonPosition,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
@@ -19,16 +21,294 @@ export type AnimatedButtonProps = ButtonProps &
soonLabel?: string;
};
type AnimatedButtonDynamicStyles = {
background: string;
borderColor: string;
borderWidthOverride: string;
boxShadow: string;
color: string;
hoverBackground: string;
activeBackground: string;
};
const computeAnimatedButtonDynamicStyles = (
variant: ButtonVariant,
inverted: boolean,
accent: ButtonAccent,
disabled: boolean,
focus: boolean,
position: ButtonPosition,
): AnimatedButtonDynamicStyles => {
const result: AnimatedButtonDynamicStyles = {
background: 'transparent',
borderColor: 'transparent',
borderWidthOverride: '',
boxShadow: 'none',
color: themeCssVariables.font.color.secondary,
hoverBackground: 'transparent',
activeBackground: 'transparent',
};
switch (variant) {
case 'primary':
switch (accent) {
case 'default':
result.background = !inverted
? themeCssVariables.background.secondary
: themeCssVariables.background.primary;
result.borderColor = !inverted
? !disabled && focus
? themeCssVariables.color.blue
: themeCssVariables.background.transparent.light
: themeCssVariables.background.transparent.light;
result.borderWidthOverride = '1px 1px 1px 1px';
result.boxShadow =
!disabled && focus
? `0 0 0 3px ${
!inverted
? themeCssVariables.accent.tertiary
: themeCssVariables.background.transparent.medium
}`
: 'none';
result.color = !inverted
? !disabled
? themeCssVariables.font.color.secondary
: themeCssVariables.font.color.extraLight
: themeCssVariables.font.color.secondary;
if (!disabled) {
result.hoverBackground = !inverted
? themeCssVariables.background.tertiary
: themeCssVariables.background.secondary;
result.activeBackground = !inverted
? themeCssVariables.background.quaternary
: themeCssVariables.background.tertiary;
} else {
result.hoverBackground = result.background;
result.activeBackground = result.background;
}
break;
case 'blue':
result.background = !inverted
? themeCssVariables.color.blue
: themeCssVariables.background.primary;
result.borderColor = !inverted
? focus
? themeCssVariables.color.blue
: themeCssVariables.background.transparent.light
: themeCssVariables.background.transparent.light;
result.borderWidthOverride = '1px 1px 1px 1px';
result.boxShadow =
!disabled && focus
? `0 0 0 3px ${
!inverted
? themeCssVariables.accent.tertiary
: themeCssVariables.background.transparent.medium
}`
: 'none';
result.color = !inverted
? themeCssVariables.grayScale.gray1
: themeCssVariables.color.blue;
if (!disabled) {
result.hoverBackground = !inverted
? themeCssVariables.color.blue10
: themeCssVariables.background.secondary;
result.activeBackground = !inverted
? themeCssVariables.color.blue12
: themeCssVariables.background.tertiary;
} else {
result.hoverBackground = result.background;
result.activeBackground = result.background;
}
break;
case 'danger':
result.background = !inverted
? themeCssVariables.color.red
: themeCssVariables.background.primary;
result.borderColor = !inverted
? focus
? themeCssVariables.color.red
: themeCssVariables.background.transparent.light
: themeCssVariables.background.transparent.light;
result.borderWidthOverride = '1px 1px';
result.boxShadow =
!disabled && focus
? `0 0 0 3px ${
!inverted
? themeCssVariables.color.red3
: themeCssVariables.background.transparent.medium
}`
: 'none';
result.color = !inverted
? themeCssVariables.background.primary
: themeCssVariables.color.red;
if (!disabled) {
result.hoverBackground = !inverted
? themeCssVariables.color.red8
: themeCssVariables.background.secondary;
result.activeBackground = !inverted
? themeCssVariables.color.red10
: themeCssVariables.background.tertiary;
} else {
result.hoverBackground = result.background;
result.activeBackground = result.background;
}
break;
}
break;
case 'secondary':
case 'tertiary':
switch (accent) {
case 'default':
result.background = 'transparent';
result.borderColor = !inverted
? variant === 'secondary'
? !disabled && focus
? themeCssVariables.color.blue
: themeCssVariables.background.transparent.medium
: focus
? themeCssVariables.color.blue
: 'transparent'
: variant === 'secondary'
? focus || disabled
? themeCssVariables.grayScale.gray1
: themeCssVariables.background.transparent.primary
: focus
? themeCssVariables.grayScale.gray1
: 'transparent';
result.borderWidthOverride = '1px 1px 1px 1px';
result.boxShadow =
!disabled && focus
? `0 0 0 3px ${
!inverted
? themeCssVariables.accent.tertiary
: themeCssVariables.background.transparent.medium
}`
: 'none';
result.color = !inverted
? !disabled
? themeCssVariables.font.color.secondary
: themeCssVariables.font.color.extraLight
: themeCssVariables.font.color.inverted;
result.hoverBackground = !inverted
? !disabled
? themeCssVariables.background.transparent.light
: 'transparent'
: themeCssVariables.background.transparent.light;
result.activeBackground = !inverted
? !disabled
? themeCssVariables.background.transparent.light
: 'transparent'
: themeCssVariables.background.transparent.medium;
break;
case 'blue':
result.background = 'transparent';
result.borderColor = !inverted
? variant === 'secondary'
? focus
? themeCssVariables.color.blue
: themeCssVariables.accent.primary
: focus
? themeCssVariables.color.blue
: 'transparent'
: variant === 'secondary'
? focus || disabled
? themeCssVariables.grayScale.gray1
: themeCssVariables.background.transparent.primary
: focus
? themeCssVariables.grayScale.gray1
: 'transparent';
result.borderWidthOverride = '1px 1px 1px 1px';
result.boxShadow =
!disabled && focus
? `0 0 0 3px ${
!inverted
? themeCssVariables.accent.tertiary
: themeCssVariables.background.transparent.medium
}`
: 'none';
result.color = !inverted
? !disabled
? themeCssVariables.color.blue
: themeCssVariables.accent.accent4060
: themeCssVariables.font.color.inverted;
result.hoverBackground = !inverted
? !disabled
? themeCssVariables.accent.tertiary
: 'transparent'
: themeCssVariables.background.transparent.light;
result.activeBackground = !inverted
? !disabled
? themeCssVariables.accent.secondary
: 'transparent'
: themeCssVariables.background.transparent.medium;
break;
case 'danger':
result.background = 'transparent';
result.borderColor = !inverted
? variant === 'secondary'
? focus
? themeCssVariables.color.red
: themeCssVariables.border.color.danger
: focus
? themeCssVariables.color.red
: 'transparent'
: variant === 'secondary'
? focus || disabled
? themeCssVariables.grayScale.gray1
: themeCssVariables.background.transparent.primary
: focus
? themeCssVariables.grayScale.gray1
: 'transparent';
result.borderWidthOverride = '1px 1px 1px 1px';
result.boxShadow =
!disabled && focus
? `0 0 0 3px ${
!inverted
? themeCssVariables.color.red3
: themeCssVariables.background.transparent.medium
}`
: 'none';
result.color = !inverted
? themeCssVariables.font.color.danger
: themeCssVariables.font.color.inverted;
result.hoverBackground = !inverted
? !disabled
? themeCssVariables.background.danger
: 'transparent'
: themeCssVariables.background.transparent.light;
result.activeBackground = !inverted
? !disabled
? themeCssVariables.background.danger
: 'transparent'
: themeCssVariables.background.transparent.medium;
break;
}
break;
}
if (result.borderWidthOverride !== '' && position !== 'standalone') {
switch (position) {
case 'left':
result.borderWidthOverride = '1px 0px 1px 1px';
break;
case 'middle':
result.borderWidthOverride = '1px 0px 1px 0px';
break;
case 'right':
result.borderWidthOverride = '1px 1px 1px 0px';
break;
}
}
return result;
};
const StyledButton = styled.button<
Pick<
ButtonProps,
| 'fullWidth'
| 'variant'
| 'inverted'
| 'size'
| 'position'
| 'accent'
| 'focus'
| 'justify'
| 'to'
| 'target'
@@ -37,292 +317,44 @@ const StyledButton = styled.button<
>
>`
align-items: center;
${({ theme, variant, inverted, accent, disabled, focus }) => {
switch (variant) {
case 'primary':
switch (accent) {
case 'default':
return css`
background: ${!inverted
? theme.background.secondary
: theme.background.primary};
border-color: ${!inverted
? !disabled && focus
? theme.color.blue
: theme.background.transparent.light
: theme.background.transparent.light};
border-width: 1px 1px 1px 1px !important;
box-shadow: ${!disabled && focus
? `0 0 0 3px ${
!inverted
? theme.accent.tertiary
: theme.background.transparent.medium
}`
: 'none'};
color: ${!inverted
? !disabled
? theme.font.color.secondary
: theme.font.color.extraLight
: theme.font.color.secondary};
&:hover {
background: ${!inverted
? theme.background.tertiary
: theme.background.secondary};
}
&:active {
background: ${!inverted
? theme.background.quaternary
: theme.background.tertiary};
}
`;
case 'blue':
return css`
background: ${!inverted
? theme.color.blue
: theme.background.primary};
border-color: ${!inverted
? focus
? theme.color.blue
: theme.background.transparent.light
: theme.background.transparent.light};
border-width: 1px 1px 1px 1px !important;
box-shadow: ${!disabled && focus
? `0 0 0 3px ${
!inverted
? theme.accent.tertiary
: theme.background.transparent.medium
}`
: 'none'};
color: ${!inverted ? theme.grayScale.gray1 : theme.color.blue};
${disabled
? ''
: css`
&:hover {
background: ${!inverted
? theme.color.blue10
: theme.background.secondary};
}
&:active {
background: ${!inverted
? theme.color.blue12
: theme.background.tertiary};
}
`}
`;
case 'danger':
return css`
background: ${!inverted
? theme.color.red
: theme.background.primary};
border-color: ${!inverted
? focus
? theme.color.red
: theme.background.transparent.light
: theme.background.transparent.light};
border-width: 1px 1px !important;
box-shadow: ${!disabled && focus
? `0 0 0 3px ${
!inverted
? theme.color.red3
: theme.background.transparent.medium
}`
: 'none'};
color: ${!inverted ? theme.background.primary : theme.color.red};
${disabled
? ''
: css`
&:hover {
background: ${!inverted
? theme.color.red8
: theme.background.secondary};
}
&:active {
background: ${!inverted
? theme.color.red10
: theme.background.tertiary};
}
`}
`;
}
break;
case 'secondary':
case 'tertiary':
switch (accent) {
case 'default':
return css`
background: transparent;
border-color: ${!inverted
? variant === 'secondary'
? !disabled && focus
? theme.color.blue
: theme.background.transparent.medium
: focus
? theme.color.blue
: 'transparent'
: variant === 'secondary'
? focus || disabled
? theme.grayScale.gray1
: theme.background.transparent.primary
: focus
? theme.grayScale.gray1
: 'transparent'};
border-width: 1px 1px 1px 1px !important;
box-shadow: ${!disabled && focus
? `0 0 0 3px ${
!inverted
? theme.accent.tertiary
: theme.background.transparent.medium
}`
: 'none'};
color: ${!inverted
? !disabled
? theme.font.color.secondary
: theme.font.color.extraLight
: theme.font.color.inverted};
&:hover {
background: ${!inverted
? !disabled
? theme.background.transparent.light
: 'transparent'
: theme.background.transparent.light};
}
&:active {
background: ${!inverted
? !disabled
? theme.background.transparent.light
: 'transparent'
: theme.background.transparent.medium};
}
`;
case 'blue':
return css`
background: transparent;
border-color: ${!inverted
? variant === 'secondary'
? focus
? theme.color.blue
: theme.accent.primary
: focus
? theme.color.blue
: 'transparent'
: variant === 'secondary'
? focus || disabled
? theme.grayScale.gray1
: theme.background.transparent.primary
: focus
? theme.grayScale.gray1
: 'transparent'};
border-width: 1px 1px 1px 1px !important;
box-shadow: ${!disabled && focus
? `0 0 0 3px ${
!inverted
? theme.accent.tertiary
: theme.background.transparent.medium
}`
: 'none'};
color: ${!inverted
? !disabled
? theme.color.blue
: theme.accent.accent4060
: theme.font.color.inverted};
&:hover {
background: ${!inverted
? !disabled
? theme.accent.tertiary
: 'transparent'
: theme.background.transparent.light};
}
&:active {
background: ${!inverted
? !disabled
? theme.accent.secondary
: 'transparent'
: theme.background.transparent.medium};
}
`;
case 'danger':
return css`
background: transparent;
border-color: ${!inverted
? variant === 'secondary'
? focus
? theme.color.red
: theme.border.color.danger
: focus
? theme.color.red
: 'transparent'
: variant === 'secondary'
? focus || disabled
? theme.grayScale.gray1
: theme.background.transparent.primary
: focus
? theme.grayScale.gray1
: 'transparent'};
border-width: 1px 1px 1px 1px !important;
box-shadow: ${!disabled && focus
? `0 0 0 3px ${
!inverted
? theme.color.red3
: theme.background.transparent.medium
}`
: 'none'};
color: ${!inverted
? theme.font.color.danger
: theme.font.color.inverted};
&:hover {
background: ${!inverted
? !disabled
? theme.background.danger
: 'transparent'
: theme.background.transparent.light};
}
&:active {
background: ${!inverted
? !disabled
? theme.background.danger
: 'transparent'
: theme.background.transparent.medium};
}
`;
}
}
}}
background: var(--abtn-bg);
border-color: var(--abtn-border-color);
border-width: var(--abtn-border-width);
box-shadow: var(--abtn-box-shadow);
color: var(--abtn-color);
&:hover {
background: var(--abtn-hover-bg);
}
&:active {
background: var(--abtn-active-bg);
}
text-decoration: none;
border-radius: ${({ position, theme }) => {
border-radius: ${({ position }) => {
switch (position) {
case 'left':
return `${theme.border.radius.sm} 0px 0px ${theme.border.radius.sm}`;
return `${themeCssVariables.border.radius.sm} 0px 0px ${themeCssVariables.border.radius.sm}`;
case 'right':
return `0px ${theme.border.radius.sm} ${theme.border.radius.sm} 0px`;
return `0px ${themeCssVariables.border.radius.sm} ${themeCssVariables.border.radius.sm} 0px`;
case 'middle':
return '0px';
case 'standalone':
return theme.border.radius.sm;
return themeCssVariables.border.radius.sm;
}
return '';
}};
border-style: solid;
border-width: ${({ variant, position }) => {
switch (variant) {
case 'primary':
case 'secondary':
return position === 'middle' ? '1px 0px' : '1px';
case 'tertiary':
return '0';
}
}};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
flex-direction: row;
font-family: ${({ theme }) => theme.font.family};
font-family: ${themeCssVariables.font.family};
font-weight: 500;
font-size: ${({ theme }) => theme.font.size.md};
gap: ${({ theme }) => theme.spacing(1)};
font-size: ${themeCssVariables.font.size.md};
gap: ${themeCssVariables.spacing[1]};
height: ${({ size }) => (size === 'small' ? '24px' : '32px')};
justify-content: ${({ justify }) => justify};
padding: ${({ theme }) => {
return `0 ${theme.spacing(2)}`;
}};
justify-content: ${({ justify }) => justify ?? ''};
padding: 0 ${themeCssVariables.spacing[2]};
transition: background 0.1s ease;
@@ -343,18 +375,20 @@ const StyledSeparator = styled.div<{
buttonSize: ButtonSize;
accent: ButtonAccent;
}>`
background: ${({ theme, accent }) => {
background: ${({ accent }) => {
switch (accent) {
case 'blue':
return theme.border.color.blue;
return themeCssVariables.border.color.blue;
case 'danger':
return theme.border.color.danger;
return themeCssVariables.border.color.danger;
default:
return theme.font.color.light;
return themeCssVariables.font.color.light;
}
}};
height: ${({ theme, buttonSize }) =>
theme.spacing(buttonSize === 'small' ? 2 : 4)};
height: ${({ buttonSize }) =>
buttonSize === 'small'
? themeCssVariables.spacing[2]
: themeCssVariables.spacing[4]};
margin: 0;
width: 1px;
`;
@@ -363,19 +397,19 @@ const StyledShortcutLabel = styled.div<{
variant: ButtonVariant;
accent: ButtonAccent;
}>`
color: ${({ theme, variant, accent }) => {
color: ${({ variant, accent }) => {
switch (accent) {
case 'blue':
return theme.border.color.blue;
return themeCssVariables.border.color.blue;
case 'danger':
return variant === 'primary'
? theme.border.color.danger
: theme.color.red8;
? themeCssVariables.border.color.danger
: themeCssVariables.color.red8;
default:
return theme.font.color.light;
return themeCssVariables.font.color.light;
}
}};
font-weight: ${({ theme }) => theme.font.weight.medium};
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledIconContainer = styled(motion.div)`
@@ -411,8 +445,29 @@ export const AnimatedButton = ({
dataGloballyPreventClickOutside,
soonLabel = 'Soon',
}: AnimatedButtonProps) => {
const theme = useTheme();
const { theme } = useContext(ThemeContext);
const isMobile = useIsMobile();
const isDisabled = soon || disabled;
const dynamicStyles = useMemo(() => {
const s = computeAnimatedButtonDynamicStyles(
variant,
inverted,
accent,
isDisabled,
focus,
position,
);
return {
'--abtn-bg': s.background,
'--abtn-border-color': s.borderColor,
'--abtn-border-width': s.borderWidthOverride || undefined,
'--abtn-box-shadow': s.boxShadow,
'--abtn-color': s.color,
'--abtn-hover-bg': s.hoverBackground,
'--abtn-active-bg': s.activeBackground,
} as React.CSSProperties;
}, [variant, inverted, accent, isDisabled, focus, position]);
const ButtonComponent = to ? Link : 'button';
@@ -420,15 +475,12 @@ export const AnimatedButton = ({
<StyledButton
as={ButtonComponent}
fullWidth={fullWidth}
variant={variant}
inverted={inverted}
size={size}
position={position}
disabled={soon || disabled}
focus={focus}
disabled={isDisabled}
justify={justify}
accent={accent}
className={className}
style={dynamicStyles}
onClick={onClick}
to={to}
target={target}