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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user