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,14 +1,13 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { styled } from '@linaria/react';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { type IconComponent } from '@ui/display';
|
||||
import { JsonArrow } from '@ui/json-visualizer/components/internal/JsonArrow';
|
||||
import { JsonList } from '@ui/json-visualizer/components/internal/JsonList';
|
||||
import { JsonNodeLabel } from '@ui/json-visualizer/components/internal/JsonNodeLabel';
|
||||
import { JsonNodeValue } from '@ui/json-visualizer/components/internal/JsonNodeValue';
|
||||
import { JsonNode } from '@ui/json-visualizer/components/JsonNode';
|
||||
import { useJsonTreeContextOrThrow } from '@ui/json-visualizer/hooks/useJsonTreeContextOrThrow';
|
||||
import { type JsonNodeHighlighting } from '@ui/json-visualizer/types/JsonNodeHighlighting';
|
||||
import { ANIMATION } from '@ui/theme';
|
||||
import { ANIMATION, themeCssVariables } from '@ui/theme';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -22,15 +21,35 @@ const StyledContainer = styled.li`
|
||||
const StyledLabelContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledElementsCount = styled.span<{ variant?: 'red' }>`
|
||||
color: ${({ theme, variant }) =>
|
||||
variant === 'red' ? theme.font.color.danger : theme.font.color.tertiary};
|
||||
const StyledElementsCount = styled.span<{
|
||||
variant?: 'red';
|
||||
}>`
|
||||
color: ${({ variant }) =>
|
||||
variant === 'red'
|
||||
? themeCssVariables.font.color.danger
|
||||
: themeCssVariables.font.color.tertiary};
|
||||
`;
|
||||
|
||||
const StyledJsonList = styled(JsonList)``.withComponent(motion.ul);
|
||||
const StyledJsonListBase = styled.ul<{
|
||||
depth: number;
|
||||
}>`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
row-gap: ${themeCssVariables.spacing[2]};
|
||||
${({ depth }) =>
|
||||
depth > 0
|
||||
? `padding-left: ${themeCssVariables.spacing[8]};
|
||||
> :first-of-type {
|
||||
margin-top: ${themeCssVariables.spacing[2]};
|
||||
}`
|
||||
: ''}
|
||||
`;
|
||||
|
||||
const StyledJsonList = motion.create(StyledJsonListBase);
|
||||
|
||||
export const JsonNestedNode = ({
|
||||
label,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { styled } from '@linaria/react';
|
||||
import { type IconComponent } from '@ui/display';
|
||||
import { JsonListItem } from '@ui/json-visualizer/components/internal/JsonListItem';
|
||||
import { JsonNodeLabel } from '@ui/json-visualizer/components/internal/JsonNodeLabel';
|
||||
import { JsonNodeValue } from '@ui/json-visualizer/components/internal/JsonNodeValue';
|
||||
import { type JsonNodeHighlighting } from '@ui/json-visualizer/types/JsonNodeHighlighting';
|
||||
import { themeCssVariables } from '@ui/theme';
|
||||
|
||||
const StyledListItem = styled(JsonListItem)`
|
||||
column-gap: ${({ theme }) => theme.spacing(2)};
|
||||
column-gap: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
type JsonValueNodeProps = {
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { styled } from '@linaria/react';
|
||||
import { VisibilityHidden } from '@ui/accessibility';
|
||||
import { IconChevronDown } from '@ui/display';
|
||||
import { useJsonTreeContextOrThrow } from '@ui/json-visualizer/hooks/useJsonTreeContextOrThrow';
|
||||
import { ANIMATION } from '@ui/theme';
|
||||
import { ANIMATION, ThemeContext, themeCssVariables } from '@ui/theme';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useContext } from 'react';
|
||||
|
||||
const StyledButton = styled(motion.button)<{ variant?: 'blue' | 'red' }>`
|
||||
const StyledButton = styled.button<{
|
||||
variant?: 'blue' | 'red';
|
||||
}>`
|
||||
align-items: center;
|
||||
background-color: ${({ theme, variant }) =>
|
||||
background-color: ${({ variant }) =>
|
||||
variant === 'red'
|
||||
? theme.background.danger
|
||||
: theme.background.transparent.lighter};
|
||||
border-color: ${({ theme, variant }) =>
|
||||
variant === 'red' ? theme.border.color.danger : theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
? themeCssVariables.background.danger
|
||||
: themeCssVariables.background.transparent.lighter};
|
||||
border-color: ${({ variant }) =>
|
||||
variant === 'red'
|
||||
? themeCssVariables.border.color.danger
|
||||
: themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-inline: ${({ theme }) => theme.spacing(1)};
|
||||
padding-inline: ${themeCssVariables.spacing[1]};
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
box-sizing: border-box;
|
||||
@@ -37,7 +41,7 @@ export const JsonArrow = ({
|
||||
onClick: () => void;
|
||||
variant?: 'blue' | 'red';
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { theme } = useContext(ThemeContext);
|
||||
|
||||
const { arrowButtonCollapsedLabel, arrowButtonExpandedLabel } =
|
||||
useJsonTreeContextOrThrow();
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { styled } from '@linaria/react';
|
||||
import { themeCssVariables } from '@ui/theme';
|
||||
|
||||
const StyledList = styled.ul<{ depth: number }>`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
display: grid;
|
||||
row-gap: ${({ theme }) => theme.spacing(2)};
|
||||
row-gap: ${themeCssVariables.spacing[2]};
|
||||
|
||||
${({ theme, depth }) =>
|
||||
depth > 0 &&
|
||||
css`
|
||||
padding-left: ${theme.spacing(8)};
|
||||
${({ depth }) =>
|
||||
depth > 0
|
||||
? `
|
||||
padding-left: ${themeCssVariables.spacing[8]};
|
||||
|
||||
> :first-of-type {
|
||||
margin-top: ${theme.spacing(2)};
|
||||
margin-top: ${themeCssVariables.spacing[2]};
|
||||
}
|
||||
`}
|
||||
`
|
||||
: ''}
|
||||
`;
|
||||
|
||||
export { StyledList as JsonList };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
const StyledListItem = styled.li`
|
||||
align-items: center;
|
||||
|
||||
@@ -1,40 +1,41 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { styled } from '@linaria/react';
|
||||
import { type IconComponent } from '@ui/display';
|
||||
import { type JsonNodeHighlighting } from '@ui/json-visualizer/types/JsonNodeHighlighting';
|
||||
import { ThemeContext, themeCssVariables } from '@ui/theme';
|
||||
import { useContext } from 'react';
|
||||
|
||||
const StyledLabelContainer = styled.span<{
|
||||
highlighting?: JsonNodeHighlighting;
|
||||
}>`
|
||||
background-color: ${({ theme, highlighting }) =>
|
||||
background-color: ${({ highlighting }) =>
|
||||
highlighting === 'blue'
|
||||
? theme.color.blue3
|
||||
? themeCssVariables.color.blue3
|
||||
: highlighting === 'red'
|
||||
? theme.background.danger
|
||||
: theme.background.transparent.lighter};
|
||||
border-color: ${({ theme, highlighting }) =>
|
||||
? themeCssVariables.background.danger
|
||||
: themeCssVariables.background.transparent.lighter};
|
||||
border-color: ${({ highlighting }) =>
|
||||
highlighting === 'blue'
|
||||
? theme.color.blue5
|
||||
? themeCssVariables.color.blue5
|
||||
: highlighting === 'red'
|
||||
? theme.border.color.danger
|
||||
: theme.border.color.medium};
|
||||
color: ${({ theme, highlighting }) =>
|
||||
? themeCssVariables.border.color.danger
|
||||
: themeCssVariables.border.color.medium};
|
||||
color: ${({ highlighting }) =>
|
||||
highlighting === 'blue'
|
||||
? theme.color.blue
|
||||
? themeCssVariables.color.blue
|
||||
: highlighting === 'red'
|
||||
? theme.font.color.danger
|
||||
: theme.font.color.primary};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
? themeCssVariables.font.color.danger
|
||||
: themeCssVariables.font.color.primary};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
column-gap: ${({ theme }) => theme.spacing(2)};
|
||||
column-gap: ${themeCssVariables.spacing[2]};
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
box-sizing: border-box;
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
white-space: nowrap;
|
||||
padding-inline: ${({ theme }) => theme.spacing(2)};
|
||||
padding-inline: ${themeCssVariables.spacing[2]};
|
||||
|
||||
> span {
|
||||
align-items: center;
|
||||
@@ -52,7 +53,7 @@ export const JsonNodeLabel = ({
|
||||
Icon: IconComponent;
|
||||
highlighting?: JsonNodeHighlighting | undefined;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { theme } = useContext(ThemeContext);
|
||||
|
||||
return (
|
||||
<StyledLabelContainer highlighting={highlighting}>
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useJsonTreeContextOrThrow } from '@ui/json-visualizer/hooks/useJsonTreeContextOrThrow';
|
||||
import { type JsonNodeHighlighting } from '@ui/json-visualizer/types/JsonNodeHighlighting';
|
||||
import { themeCssVariables } from '@ui/theme';
|
||||
|
||||
const StyledText = styled.span<{
|
||||
highlighting: JsonNodeHighlighting | undefined;
|
||||
}>`
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
color: ${({ theme, highlighting }) =>
|
||||
color: ${({ highlighting }) =>
|
||||
highlighting === 'blue'
|
||||
? theme.color.blue8
|
||||
? themeCssVariables.color.blue8
|
||||
: highlighting === 'red'
|
||||
? theme.color.red8
|
||||
: theme.font.color.tertiary};
|
||||
? themeCssVariables.color.red8
|
||||
: themeCssVariables.font.color.tertiary};
|
||||
display: inline-flex;
|
||||
height: 24px;
|
||||
line-height: 1;
|
||||
|
||||
Reference in New Issue
Block a user