Files
twenty/packages/twenty-new-ui/src/theme-constants/ThemeProvider.tsx
T
Raphaël Bosi 6f9b59b224 Scaffold twenty-new-ui (#21236)
Scaffolds `twenty-new-ui`, the next-gen replacement for `twenty-ui`, on
**SCSS** Modules + **Base UI** (no Linaria).

- **Tooling**: Vite lib build, subpaths mirror twenty-ui, typed SCSS
Modules, Storybook + axe a11y, size-limit, Nx targets.
- **Theme**: single token source → nx generateTheme emits the CSS vars +
accessor; parity test asserts token-for-token match with twenty-ui.

Migrated a first `Toggle` component with its stories to allow
@charlesBochet to wire the new pixel-diff system.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:42:57 +00:00

122 lines
3.2 KiB
TypeScript

import { createContext, useLayoutEffect, useState } from 'react';
import { themeCssVariables } from './themeCssVariables';
type StringLeaves<T> = {
[K in keyof T]: T[K] extends string ? string : StringLeaves<T[K]>;
};
type DeepMerge<T, U> = {
[K in keyof T]: K extends keyof U
? U[K] extends Record<string, unknown>
? T[K] extends Record<string, unknown>
? DeepMerge<T[K], U[K]>
: U[K]
: U[K]
: T[K];
};
// CSS variables that resolve to pure numbers at runtime
type NumericOverrides = {
icon: {
size: { sm: number; md: number; lg: number; xl: number };
stroke: { sm: number; md: number; lg: number };
};
animation: {
duration: { instant: number; fast: number; normal: number; slow: number };
};
text: {
lineHeight: { lg: number; md: number };
iconSizeMedium: number;
iconSizeSmall: number;
iconStrikeLight: number;
iconStrikeMedium: number;
iconStrikeBold: number;
};
spacingMultiplicator: number;
lastLayerZIndex: number;
};
export type ThemeType = DeepMerge<
StringLeaves<typeof themeCssVariables>,
NumericOverrides
>;
export type ThemeContextType = {
theme: ThemeType;
colorScheme: 'light' | 'dark';
};
const computeThemeFromCss = (): ThemeType => {
if (
typeof document === 'undefined' ||
typeof getComputedStyle !== 'function'
) {
return themeCssVariables as unknown as ThemeType;
}
const computedStyle = getComputedStyle(document.documentElement);
const resolve = (obj: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {};
for (const key of Object.keys(obj)) {
const value = obj[key];
if (typeof value === 'string' && value.startsWith('var(')) {
const varName = value.slice(4, -1);
const raw = computedStyle.getPropertyValue(varName).trim();
const num = Number(raw);
result[key] = raw !== '' && !isNaN(num) ? num : raw;
} else if (typeof value === 'object' && value !== null) {
result[key] = resolve(value as Record<string, unknown>);
} else {
result[key] = value;
}
}
return result;
};
return resolve(
themeCssVariables as unknown as Record<string, unknown>,
) as unknown as ThemeType;
};
const applyColorSchemeClass = (colorScheme: 'light' | 'dark') => {
if (typeof document === 'undefined') return;
const root = document.documentElement;
if (!root?.classList) return;
root.classList.toggle('dark', colorScheme === 'dark');
root.classList.toggle('light', colorScheme === 'light');
};
export const ThemeContext = createContext<ThemeContextType>({
theme: themeCssVariables as unknown as ThemeType,
colorScheme: 'light',
});
export const ThemeProvider = ({
children,
colorScheme,
}: {
children: React.ReactNode;
colorScheme: 'light' | 'dark';
}) => {
const [theme, setTheme] = useState<ThemeType>(() => {
applyColorSchemeClass(colorScheme);
return computeThemeFromCss();
});
useLayoutEffect(() => {
applyColorSchemeClass(colorScheme);
setTheme(computeThemeFromCss());
}, [colorScheme]);
return (
<ThemeContext.Provider value={{ theme, colorScheme }}>
{children}
</ThemeContext.Provider>
);
};