From 195735255f414f15ebe2e82ceb87423f23044ad2 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 3 Feb 2026 15:40:05 -0500 Subject: [PATCH 1/5] init --- packages/ui/src/mosaic/button.ts | 79 ++++ packages/ui/src/mosaic/index.ts | 4 + packages/ui/src/mosaic/text.ts | 48 +++ packages/ui/src/mosaic/theme-provider.tsx | 19 + packages/ui/src/mosaic/theme.ts | 287 ++++++++++++++ packages/ui/src/mosaic/variants.test.ts | 380 +++++++++++++++++++ packages/ui/src/mosaic/variants.ts | 183 +++++++++ packages/ui/src/mosaic/variants.type.test.ts | 253 ++++++++++++ 8 files changed, 1253 insertions(+) create mode 100644 packages/ui/src/mosaic/button.ts create mode 100644 packages/ui/src/mosaic/index.ts create mode 100644 packages/ui/src/mosaic/text.ts create mode 100644 packages/ui/src/mosaic/theme-provider.tsx create mode 100644 packages/ui/src/mosaic/theme.ts create mode 100644 packages/ui/src/mosaic/variants.test.ts create mode 100644 packages/ui/src/mosaic/variants.ts create mode 100644 packages/ui/src/mosaic/variants.type.test.ts diff --git a/packages/ui/src/mosaic/button.ts b/packages/ui/src/mosaic/button.ts new file mode 100644 index 00000000000..21326c01cfe --- /dev/null +++ b/packages/ui/src/mosaic/button.ts @@ -0,0 +1,79 @@ +import { style, variants } from './variants'; + +export const buttonStyles = variants({ + base: style(theme => ({ + appearance: 'none', + boxSizing: 'border-box', + position: 'relative', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: theme.spacing[1], + height: theme.spacing[7], + borderRadius: theme.spacing[1.5], + fontSize: theme.typography.label[3].fontSize, + fontWeight: theme.fontWeights.medium, + fontFamily: theme.fontFamilies.sans, + border: 'none', + outline: 'none', + margin: 0, + paddingBlock: 0, + paddingInline: theme.spacing[3], + cursor: 'pointer', + textDecoration: 'none', + ...theme.fontRendering, + isolation: 'isolate', + transition: '300ms cubic-bezier(0.4, 0.36, 0, 1)', + '&::before': { + content: '""', + position: 'absolute', + inset: 0, + zIndex: -1, + borderRadius: 'inherit', + pointerEvents: 'none', + background: `linear-gradient(180deg, ${theme.alpha(theme.colors.white, 20)} 0%, ${theme.alpha(theme.colors.white, 0)} 100%)`, + opacity: 0.5, + transition: 'opacity 300ms cubic-bezier(0.4, 0.36, 0, 1)', + }, + '&::after': { + content: '""', + position: 'absolute', + inset: 0, + zIndex: -1, + borderRadius: 'inherit', + pointerEvents: 'none', + background: `linear-gradient(180deg, ${theme.alpha(theme.colors.white, 10)} 46%, ${theme.alpha(theme.colors.white, 0)} 54%)`, + mixBlendMode: 'overlay', + }, + '@media (hover: hover)': { + '&:hover::before': { + opacity: 1, + }, + }, + '&:focus-visible': { + ...theme.focusRing(), + }, + })), + variants: { + variant: { + primary: style(theme => ({ + background: theme.colors.purple[700], + color: theme.colors.white, + boxShadow: `${theme.colors.white} 0px 0px 0px 0px, ${theme.colors.purple[700]} 0px 0px 0px 1px, ${theme.alpha(theme.colors.white, 7)} 0px 1px 0px 0px inset, ${theme.alpha(theme.colors.gray[1300], 20)} 0px 1px 3px 0px`, + })), + secondary: style(theme => ({ + background: theme.colors.gray[1100], + color: theme.colors.white, + boxShadow: `${theme.colors.white} 0px 0px 0px 0px, ${theme.colors.gray[1100]} 0px 0px 0px 1px, ${theme.alpha(theme.colors.white, 7)} 0px 1px 0px 0px inset, ${theme.alpha(theme.colors.gray[1300], 20)} 0px 1px 3px 0px`, + })), + }, + fullWidth: { + true: { width: '100%' }, + false: {}, + }, + }, + defaultVariants: { + variant: 'primary', + fullWidth: false, + }, +}); diff --git a/packages/ui/src/mosaic/index.ts b/packages/ui/src/mosaic/index.ts new file mode 100644 index 00000000000..4f4209f7c55 --- /dev/null +++ b/packages/ui/src/mosaic/index.ts @@ -0,0 +1,4 @@ +export { variants } from './variants'; +export { buttonStyles } from './button'; +export { MosaicThemeProvider, useMosaicTheme } from './theme-provider'; +export { mosaicTheme, type MosaicTheme } from './theme'; diff --git a/packages/ui/src/mosaic/text.ts b/packages/ui/src/mosaic/text.ts new file mode 100644 index 00000000000..4dd2939aa46 --- /dev/null +++ b/packages/ui/src/mosaic/text.ts @@ -0,0 +1,48 @@ +import { style, variants } from './variants'; + +export const textStyles = variants({ + base: style(theme => ({ + boxSizing: 'border-box', + padding: 0, + margin: 0, + background: 'none', + border: 'none', + fontFamily: theme.fontFamilies.sans, + textDecoration: 'none', + ...theme.fontRendering, + })), + variants: { + variant: { + 'heading-1': style(theme => ({ ...theme.typography.heading[1] })), + 'heading-2': style(theme => ({ ...theme.typography.heading[2] })), + 'heading-3': style(theme => ({ ...theme.typography.heading[3] })), + 'heading-4': style(theme => ({ ...theme.typography.heading[4] })), + 'heading-5': style(theme => ({ ...theme.typography.heading[5] })), + 'heading-6': style(theme => ({ ...theme.typography.heading[6] })), + 'label-1': style(theme => ({ ...theme.typography.label[1] })), + 'label-2': style(theme => ({ ...theme.typography.label[2] })), + 'label-3': style(theme => ({ ...theme.typography.label[3] })), + 'label-4': style(theme => ({ ...theme.typography.label[4] })), + 'label-5': style(theme => ({ ...theme.typography.label[5] })), + 'body-1': style(theme => ({ ...theme.typography.body[1] })), + 'body-2': style(theme => ({ ...theme.typography.body[2] })), + 'body-3': style(theme => ({ ...theme.typography.body[3] })), + 'body-4': style(theme => ({ ...theme.typography.body[4] })), + }, + color: { + default: style(theme => ({ color: theme.colors.white })), + muted: style(theme => ({ color: theme.colors.gray[500] })), + subtle: style(theme => ({ color: theme.colors.gray[400] })), + accent: style(theme => ({ color: theme.colors.purple[400] })), + }, + font: { + sans: style(theme => ({ fontFamily: theme.fontFamilies.sans })), + mono: style(theme => ({ fontFamily: theme.fontFamilies.mono })), + }, + }, + defaultVariants: { + variant: 'body-2', + color: 'default', + font: 'sans', + }, +}); diff --git a/packages/ui/src/mosaic/theme-provider.tsx b/packages/ui/src/mosaic/theme-provider.tsx new file mode 100644 index 00000000000..f8b25690e5f --- /dev/null +++ b/packages/ui/src/mosaic/theme-provider.tsx @@ -0,0 +1,19 @@ +// eslint-disable-next-line no-restricted-imports +import createCache from '@emotion/cache'; +// eslint-disable-next-line no-restricted-imports +import { CacheProvider, ThemeContext } from '@emotion/react'; +import React from 'react'; + +import { type MosaicTheme, mosaicTheme } from './theme'; + +const mosaicCache = createCache({ key: 'mosaic' }); + +export const MosaicThemeProvider = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ); +}; + +export const useMosaicTheme = () => React.useContext(ThemeContext) as MosaicTheme; diff --git a/packages/ui/src/mosaic/theme.ts b/packages/ui/src/mosaic/theme.ts new file mode 100644 index 00000000000..e0cd8adb884 --- /dev/null +++ b/packages/ui/src/mosaic/theme.ts @@ -0,0 +1,287 @@ +// Ceramic Colors (from CSS variables) +const gray = { + 50: '#fafafb', + 100: '#f6f6f7', + 200: '#ececee', + 300: '#dbdbe0', + 400: '#c7c7cf', + 500: '#adadb7', + 600: '#90909d', + 700: '#767684', + 800: '#5f5f6f', + 900: '#4c4c5c', + 1000: '#3d3d4a', + 1100: '#33333e', + 1200: '#2b2b34', + 1300: '#232328', + 1400: '#1b1b1f', + 1500: '#111113', +} as const; + +const purple = { + 50: '#f5f3ff', + 100: '#e3e0ff', + 200: '#ccc8ff', + 300: '#bab0ff', + 400: '#a698ff', + 500: '#9280ff', + 600: '#846bff', + 700: '#6c47ff', + 800: '#5f15fe', + 900: '#4d06d1', + 1000: '#3707a6', + 1100: '#27057c', + 1200: '#1c045f', + 1300: '#16034b', +} as const; + +const green = { + 50: '#effdf1', + 100: '#aff9bf', + 200: '#65f088', + 300: '#49dc6e', + 400: '#31c854', + 500: '#1eb43c', + 600: '#199d34', + 700: '#15892b', + 800: '#107524', + 900: '#09661c', + 1000: '#0b5619', + 1100: '#0c4919', + 1200: '#0c3c18', + 1300: '#053211', +} as const; + +const red = { + 50: '#fef8f8', + 100: '#fedddd', + 200: '#fec4c4', + 300: '#fca9a9', + 400: '#f98a8a', + 500: '#f86969', + 600: '#f73d3d', + 700: '#e02e2e', + 800: '#c22a2a', + 900: '#aa1b1b', + 1000: '#921414', + 1100: '#7a1313', + 1200: '#651414', + 1300: '#550e0e', + 1400: '#3d0101', + 1500: '#2d0101', +} as const; + +const orange = { + 50: '#fff8f2', + 100: '#ffe4c4', + 200: '#fecc9f', + 300: '#feb166', + 400: '#fd9357', + 500: '#fd7224', + 600: '#e06213', + 700: '#c3540f', + 800: '#a8470c', + 900: '#9d3405', + 1000: '#8a2706', + 1100: '#75220b', + 1200: '#5f1e0c', + 1300: '#50170a', +} as const; + +const yellow = { + 50: '#fefbdc', + 100: '#f7ed55', + 200: '#e5d538', + 300: '#d7be35', + 400: '#c0aa18', + 500: '#bd9005', + 600: '#a47c04', + 700: '#8d6b03', + 800: '#775902', + 900: '#674401', + 1000: '#563202', + 1100: '#412303', + 1200: '#321904', + 1300: '#2a1203', +} as const; + +const blue = { + 50: '#f6faff', + 100: '#daeafe', + 200: '#b4d5fe', + 300: '#8dc2fd', + 400: '#73acfa', + 500: '#6694f8', + 600: '#307ff6', + 700: '#236dd7', + 800: '#1c5bb6', + 900: '#1744a6', + 1000: '#0f318e', + 1100: '#0e2369', + 1200: '#0b1c49', + 1300: '#0c1637', +} as const; + +// Typography - Labels +const label = { + 1: { fontSize: '1rem', lineHeight: '1.375rem', fontWeight: 500 }, + 2: { fontSize: '0.875rem', lineHeight: '1.25rem', fontWeight: 500 }, + 3: { fontSize: '0.75rem', lineHeight: '1rem', fontWeight: 500 }, + 4: { + fontSize: '0.6875rem', + lineHeight: '0.875rem', + fontWeight: 500, + letterSpacing: '0.015em', + }, + 5: { fontSize: '0.625rem', lineHeight: '0.8125rem', fontWeight: 500 }, +} as const; + +// Typography - Headings +const heading = { + 1: { + fontSize: '2.25rem', + lineHeight: '2.5rem', + fontWeight: 500, + letterSpacing: '-0.02em', + }, + 2: { + fontSize: '2rem', + lineHeight: '2.25rem', + fontWeight: 500, + letterSpacing: '-0.02em', + }, + 3: { + fontSize: '1.75rem', + lineHeight: '2.125rem', + fontWeight: 500, + letterSpacing: '-0.015em', + }, + 4: { + fontSize: '1.5rem', + lineHeight: '2rem', + fontWeight: 500, + letterSpacing: '-0.01em', + }, + 5: { + fontSize: '1.25rem', + lineHeight: '1.75rem', + fontWeight: 500, + letterSpacing: '-0.01em', + }, + 6: { fontSize: '1.0625rem', lineHeight: '1.5rem', fontWeight: 500 }, +} as const; + +// Typography - Body +const body = { + 1: { fontSize: '1rem', lineHeight: '1.375rem', fontWeight: 400 }, + 2: { fontSize: '0.875rem', lineHeight: '1.25rem', fontWeight: 400 }, + 3: { + fontSize: '0.75rem', + lineHeight: '1rem', + fontWeight: 400, + letterSpacing: '0.01em', + }, + 4: { fontSize: '0.6875rem', lineHeight: '0.875rem', fontWeight: 400 }, +} as const; + +const fontWeights = { + normal: 400, + medium: 500, + semibold: 600, + bold: 700, +} as const; + +const fontFamilies = { + sans: 'system-ui, sans-serif', + mono: 'ui-monospace, monospace', +} as const; + +// Spacing (Tailwind-compatible scale, 1 unit = 0.25rem = 4px) +const spacing = { + 0: '0', + px: '1px', + 0.5: '0.125rem', + 1: '0.25rem', + 1.5: '0.375rem', + 2: '0.5rem', + 2.5: '0.625rem', + 3: '0.75rem', + 3.5: '0.875rem', + 4: '1rem', + 5: '1.25rem', + 6: '1.5rem', + 7: '1.75rem', + 8: '2rem', + 9: '2.25rem', + 10: '2.5rem', + 11: '2.75rem', + 12: '3rem', + 14: '3.5rem', + 16: '4rem', + 20: '5rem', + 24: '6rem', + 28: '7rem', + 32: '8rem', + 36: '9rem', + 40: '10rem', + 44: '11rem', + 48: '12rem', + 52: '13rem', + 56: '14rem', + 60: '15rem', + 64: '16rem', + 72: '18rem', + 80: '20rem', + 96: '24rem', +} as const; + +/** + * Creates a transparent color using color-mix(). + * Browser support: Chrome 111+, Firefox 113+, Safari 16.4+ + * For older browsers, consider using rgba() fallback or CSS @supports queries. + */ +const alpha = (color: string, opacity: number) => `color-mix(in srgb, ${color} ${opacity}%, transparent)`; +const negative = (value: string) => `-${value}`; + +const baseTheme = { + colors: { + gray, + purple, + green, + red, + orange, + yellow, + blue, + white: '#fff', + black: '#000', + }, + typography: { + label, + heading, + body, + }, + fontWeights, + fontFamilies, + spacing, + alpha, + negative, +} as const; + +const fontRendering = { + WebkitFontSmoothing: 'auto', + MozOsxFontSmoothing: 'auto', + textRendering: 'auto', + WebkitTextSizeAdjust: '100%', + textSizeAdjust: '100%', +} as const; + +export const mosaicTheme = { + ...baseTheme, + focusRing: () => ({ + outline: `2px solid ${baseTheme.colors.purple[700]}`, + outlineOffset: '2px', + }), + fontRendering, +} as const; + +export type MosaicTheme = typeof mosaicTheme; diff --git a/packages/ui/src/mosaic/variants.test.ts b/packages/ui/src/mosaic/variants.test.ts new file mode 100644 index 00000000000..32d894a769c --- /dev/null +++ b/packages/ui/src/mosaic/variants.test.ts @@ -0,0 +1,380 @@ +// eslint-disable-next-line no-restricted-imports +import type { Interpolation, Theme } from '@emotion/react'; +import { describe, expect, it } from 'vitest'; + +import { mosaicTheme } from './theme'; +import { style, variants } from './variants'; + +// Helper to extract the CSS object from Interpolation +function getStyles(interpolation: Interpolation): Record { + if (typeof interpolation === 'function') { + return interpolation(mosaicTheme as unknown as Theme) as Record; + } + if (interpolation == null) { + return {}; + } + return interpolation as Record; +} + +describe('variants', () => { + it('applies base styles', () => { + const buttonStyles = variants({ + base: { padding: '1rem', margin: '0.5rem' }, + variants: {}, + }); + + const result = getStyles(buttonStyles()); + expect(result).toEqual({ padding: '1rem', margin: '0.5rem' }); + }); + + it('applies base styles from function', () => { + const buttonStyles = variants({ + base: style(theme => ({ + padding: theme.spacing[4], + color: theme.colors.white, + })), + variants: {}, + }); + + const result = getStyles(buttonStyles()); + expect(result).toEqual({ + padding: mosaicTheme.spacing[4], + color: mosaicTheme.colors.white, + }); + }); + + it('applies variant styles based on props', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem', padding: '0.5rem' }, + md: { fontSize: '1rem', padding: '1rem' }, + lg: { fontSize: '1.25rem', padding: '1.5rem' }, + }, + }, + }); + + const result = getStyles(buttonStyles({ size: 'sm' })); + expect(result).toEqual({ fontSize: '0.75rem', padding: '0.5rem' }); + }); + + it('applies variant styles from function', () => { + const buttonStyles = variants({ + variants: { + variant: { + primary: style(theme => ({ + background: theme.colors.purple[700], + color: theme.colors.white, + })), + secondary: style(theme => ({ + background: theme.colors.gray[200], + color: theme.colors.gray[1200], + })), + }, + }, + }); + + const result = getStyles(buttonStyles({ variant: 'primary' })); + expect(result).toEqual({ + background: mosaicTheme.colors.purple[700], + color: mosaicTheme.colors.white, + }); + }); + + it('applies multiple variants', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + variant: { + primary: { background: 'blue' }, + secondary: { background: 'gray' }, + }, + }, + }); + + const result = getStyles(buttonStyles({ size: 'sm', variant: 'primary' })); + expect(result).toEqual({ fontSize: '0.75rem', background: 'blue' }); + }); + + it('applies default variants when props are not provided', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + variant: { + primary: { background: 'blue' }, + secondary: { background: 'gray' }, + }, + }, + defaultVariants: { + size: 'md', + variant: 'primary', + }, + }); + + const result = getStyles(buttonStyles()); + expect(result).toEqual({ fontSize: '1rem', background: 'blue' }); + }); + + it('overrides default variants with props', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + }, + defaultVariants: { + size: 'md', + }, + }); + + const result = getStyles(buttonStyles({ size: 'sm' })); + expect(result).toEqual({ fontSize: '0.75rem' }); + }); + + it('applies boolean variants with true/false keys', () => { + const buttonStyles = variants({ + variants: { + fullWidth: { + true: { width: '100%' }, + false: { width: 'fit-content' }, + }, + disabled: { + true: { opacity: 0.5, cursor: 'not-allowed' }, + false: { opacity: 1, cursor: 'pointer' }, + }, + }, + }); + + const result = getStyles(buttonStyles({ fullWidth: true, disabled: false })); + expect(result).toEqual({ width: '100%', opacity: 1, cursor: 'pointer' }); + }); + + it('applies boolean variants from default variants', () => { + const buttonStyles = variants({ + variants: { + fullWidth: { + true: { width: '100%' }, + false: { width: 'fit-content' }, + }, + }, + defaultVariants: { + fullWidth: false, + }, + }); + + const result = getStyles(buttonStyles()); + expect(result).toEqual({ width: 'fit-content' }); + }); + + it('applies compound variants when conditions match', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + variant: { + primary: { background: 'blue' }, + secondary: { background: 'gray' }, + }, + }, + compoundVariants: [ + { + condition: { size: 'sm', variant: 'primary' }, + styles: { borderRadius: '0.25rem' }, + }, + { + condition: { size: 'md', variant: 'secondary' }, + styles: { borderRadius: '0.5rem' }, + }, + ], + }); + + const result = getStyles(buttonStyles({ size: 'sm', variant: 'primary' })); + expect(result).toEqual({ + fontSize: '0.75rem', + background: 'blue', + borderRadius: '0.25rem', + }); + }); + + it('applies compound variants with function styles', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + variant: { + primary: { background: 'blue' }, + }, + }, + compoundVariants: [ + { + condition: { size: 'sm', variant: 'primary' }, + styles: style(theme => ({ + border: `1px solid ${theme.colors.purple[700]}`, + })), + }, + ], + }); + + const result = getStyles(buttonStyles({ size: 'sm', variant: 'primary' })); + expect(result).toEqual({ + fontSize: '0.75rem', + background: 'blue', + border: `1px solid ${mosaicTheme.colors.purple[700]}`, + }); + }); + + it('does not apply compound variants when conditions do not match', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + variant: { + primary: { background: 'blue' }, + }, + }, + compoundVariants: [ + { + condition: { size: 'sm', variant: 'primary' }, + styles: { borderRadius: '0.25rem' }, + }, + ], + }); + + const result = getStyles(buttonStyles({ size: 'md', variant: 'primary' })); + expect(result).toEqual({ + fontSize: '1rem', + background: 'blue', + }); + }); + + it('merges base styles with variant styles', () => { + const buttonStyles = variants({ + base: { padding: '1rem', margin: '0.5rem' }, + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + }, + }); + + const result = getStyles(buttonStyles({ size: 'sm' })); + expect(result).toEqual({ + padding: '1rem', + margin: '0.5rem', + fontSize: '0.75rem', + }); + }); + + it('merges styles with later variants overriding earlier ones', () => { + const buttonStyles = variants({ + base: { fontSize: '1rem' }, + variants: { + size: { + sm: { fontSize: '0.75rem' }, + }, + }, + }); + + const result = getStyles(buttonStyles({ size: 'sm' })); + expect(result).toEqual({ fontSize: '0.75rem' }); + }); + + it('handles nested pseudo-selectors', () => { + const buttonStyles = variants({ + base: { + '&:hover': { opacity: 0.8 }, + '&:active': { opacity: 0.6 }, + }, + variants: { + variant: { + primary: { + '&:hover': { background: 'darkblue' }, + }, + }, + }, + }); + + const result = getStyles(buttonStyles({ variant: 'primary' })); + expect(result).toEqual({ + '&:hover': { opacity: 0.8, background: 'darkblue' }, + '&:active': { opacity: 0.6 }, + }); + }); + + it('handles null and undefined props', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + }, + defaultVariants: { + size: 'md', + }, + }); + + const result1 = getStyles(buttonStyles({ size: null as any })); + const result2 = getStyles(buttonStyles({ size: undefined as any })); + expect(result1).toEqual({ fontSize: '1rem' }); + expect(result2).toEqual({ fontSize: '1rem' }); + }); + + it('handles empty variants object', () => { + const buttonStyles = variants({ + base: { padding: '1rem' }, + variants: {}, + }); + + const result = getStyles(buttonStyles()); + expect(result).toEqual({ padding: '1rem' }); + }); + + it('handles missing variant definition gracefully', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + }, + }, + }); + + const result = getStyles(buttonStyles({ size: 'nonexistent' as any })); + expect(result).toEqual({}); + }); +}); + +describe('style', () => { + it('returns the function unchanged', () => { + const fn = (theme: typeof mosaicTheme) => ({ color: theme.colors.white }); + const result = style(fn); + expect(result).toBe(fn); + }); + + it('provides MosaicTheme typing', () => { + const fn = style(theme => ({ + color: theme.colors.white, + padding: theme.spacing[4], + })); + + const result = fn(mosaicTheme); + expect(result).toEqual({ + color: mosaicTheme.colors.white, + padding: mosaicTheme.spacing[4], + }); + }); +}); diff --git a/packages/ui/src/mosaic/variants.ts b/packages/ui/src/mosaic/variants.ts new file mode 100644 index 00000000000..45fb2623c6f --- /dev/null +++ b/packages/ui/src/mosaic/variants.ts @@ -0,0 +1,183 @@ +import { fastDeepMergeAndReplace } from '@clerk/shared/utils'; +// eslint-disable-next-line no-restricted-imports +import type { Interpolation, Theme } from '@emotion/react'; + +import { type MosaicTheme } from './theme'; + +type CSSObject = Record; +// StyleFunction uses MosaicTheme to provide proper typing for theme parameter +type StyleFunction = (theme: MosaicTheme) => CSSObject; +type StyleRule = CSSObject | StyleFunction; + +// Convert string literal "true" | "false" to boolean (CVA's StringToBoolean) +type StringToBoolean = T extends 'true' | 'false' ? boolean : T; + +/** + * Extracts variant props type from a variant style function. + * + * @example + * ```ts + * const buttonStyles = variants({ + * variants: { + * size: { sm: {...}, md: {...} }, + * variant: { primary: {...}, secondary: {...} }, + * }, + * }); + * + * type ButtonProps = VariantProps; + * // { size?: 'sm' | 'md' | null | undefined; variant?: 'primary' | 'secondary' | null | undefined } + * + * function Button(props: ButtonProps) { + * return + * ``` + */ +export function variants(config: VariantsConfig) { + const { base, variants: variantDefinitions = {} as T, defaultVariants = {}, compoundVariants = [] } = config; + + return (props: ConfigVariants = {}): Interpolation => { + return ((theme: Theme) => { + const mosaicTheme = theme as unknown as MosaicTheme; + const computedStyles: CSSObject = {}; + const variantDefs = variantDefinitions as Record>; + + const baseStyles = resolveStyleRule(base, mosaicTheme); + if (baseStyles && typeof baseStyles === 'object') { + fastDeepMergeAndReplace(baseStyles, computedStyles); + } + + const variantsToApply = calculateVariantsToBeApplied( + variantDefs, + props as Record, + defaultVariants as Record, + ); + + for (const variantKey in variantsToApply) { + const variantValue = variantsToApply[variantKey]; + const variantDef = variantDefs[variantKey]; + const normalizedValue = normalizeVariantValue(variantValue); + if (variantDef && normalizedValue in variantDef) { + const variantStyle = resolveStyleRule(variantDef[normalizedValue], mosaicTheme); + fastDeepMergeAndReplace(variantStyle, computedStyles); + } + } + + for (const compoundVariant of compoundVariants) { + if (conditionMatches(compoundVariant, variantsToApply)) { + const compoundStyles = resolveStyleRule(compoundVariant.styles, mosaicTheme); + fastDeepMergeAndReplace(compoundStyles, computedStyles); + } + } + + return computedStyles; + }) as unknown as Interpolation; + }; +} diff --git a/packages/ui/src/mosaic/variants.type.test.ts b/packages/ui/src/mosaic/variants.type.test.ts new file mode 100644 index 00000000000..b8a7265f952 --- /dev/null +++ b/packages/ui/src/mosaic/variants.type.test.ts @@ -0,0 +1,253 @@ +import { describe, expectTypeOf, it } from 'vitest'; + +import type { MosaicTheme } from './theme'; +import { style, variants } from './variants'; + +describe('variants type tests', () => { + describe('boolean variants', () => { + it('allows boolean values in defaultVariants for true/false keys', () => { + const _buttonStyles = variants({ + variants: { + fullWidth: { + true: { width: '100%' }, + false: { width: 'fit-content' }, + }, + }, + defaultVariants: { + fullWidth: false, + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ fullWidth: false }>().toMatchTypeOf(); + expectTypeOf<{ fullWidth: true }>().toMatchTypeOf(); + }); + + it('allows boolean values in props for true/false keys', () => { + const _buttonStyles = variants({ + variants: { + disabled: { + true: { opacity: 0.5 }, + false: { opacity: 1 }, + }, + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ disabled: true }>().toMatchTypeOf(); + expectTypeOf<{ disabled: false }>().toMatchTypeOf(); + }); + + it('does not allow string values for boolean variants', () => { + const _buttonStyles = variants({ + variants: { + fullWidth: { + true: { width: '100%' }, + false: { width: 'fit-content' }, + }, + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ fullWidth: 'true' }>().not.toMatchTypeOf(); + }); + }); + + describe('string variants', () => { + it('allows string literal values in defaultVariants', () => { + const _buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + lg: { fontSize: '1.25rem' }, + }, + }, + defaultVariants: { + size: 'md', + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ size: 'sm' }>().toMatchTypeOf(); + expectTypeOf<{ size: 'md' }>().toMatchTypeOf(); + expectTypeOf<{ size: 'lg' }>().toMatchTypeOf(); + }); + + it('does not allow invalid string values', () => { + const _buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ size: 'xl' }>().not.toMatchTypeOf(); + }); + }); + + describe('mixed variants', () => { + it('allows mixing boolean and string variants', () => { + const _buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + fullWidth: { + true: { width: '100%' }, + false: { width: 'fit-content' }, + }, + }, + defaultVariants: { + size: 'sm', + fullWidth: false, + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ size: 'md'; fullWidth: true }>().toMatchTypeOf(); + }); + }); + + describe('style helper', () => { + it('provides MosaicTheme typing for theme parameter', () => { + const fn = style(theme => { + // Theme parameter should be typed as MosaicTheme + return { color: theme.colors.white }; + }); + + expectTypeOf(fn).toBeFunction(); + expectTypeOf[0]>().toEqualTypeOf(); + }); + + it('returns StyleFunction type', () => { + const _fn = style(theme => ({ color: theme.colors.white })); + type Return = ReturnType; + expectTypeOf().toMatchTypeOf>(); + }); + }); + + describe('props type inference', () => { + it('infers correct props type from variants', () => { + const _buttonStyles = variants({ + variants: { + variant: { + primary: { background: 'blue' }, + secondary: { background: 'gray' }, + }, + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + }, + }); + + type Props = Parameters[0]; + // Props should allow optional variant and size + expectTypeOf<{ variant?: 'primary' | 'secondary'; size?: 'sm' | 'md' }>().toMatchTypeOf(); + }); + + it('allows partial props', () => { + const _buttonStyles = variants({ + variants: { + variant: { + primary: { background: 'blue' }, + secondary: { background: 'gray' }, + }, + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ variant: 'primary' }>().toMatchTypeOf(); + expectTypeOf<{ size: 'md' }>().toMatchTypeOf(); + expectTypeOf>().toMatchTypeOf(); + }); + + it('allows null and undefined in props', () => { + const _buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ size: null }>().toMatchTypeOf(); + expectTypeOf<{ size: undefined }>().toMatchTypeOf(); + }); + }); + + describe('compound variants', () => { + it('allows compound variants with correct condition types', () => { + const _buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + variant: { + primary: { background: 'blue' }, + secondary: { background: 'gray' }, + }, + }, + compoundVariants: [ + { + condition: { size: 'sm', variant: 'primary' }, + styles: { borderRadius: '0.25rem' }, + }, + ], + }); + + // Type should compile without errors + expectTypeOf(_buttonStyles).toBeFunction(); + }); + + it('allows boolean values in compound variant conditions', () => { + const _buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + }, + fullWidth: { + true: { width: '100%' }, + false: { width: 'fit-content' }, + }, + }, + compoundVariants: [ + { + condition: { size: 'sm', fullWidth: true }, + styles: { padding: '0.5rem' }, + }, + ], + }); + + // Type should compile without errors + expectTypeOf(_buttonStyles).toBeFunction(); + }); + }); + + describe('return type', () => { + it('returns Interpolation', () => { + const _buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + }, + }, + }); + + // Verify the function can be called with props + expectTypeOf(_buttonStyles({ size: 'sm' })).toMatchTypeOf(); + }); + }); +}); From b12f04db6f98b6725ff3b9f4d1b04adc61572590 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 3 Feb 2026 15:48:11 -0500 Subject: [PATCH 2/5] add semantic tokens --- packages/ui/src/mosaic/text.ts | 8 ++++---- packages/ui/src/mosaic/theme.ts | 9 +++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/mosaic/text.ts b/packages/ui/src/mosaic/text.ts index 4dd2939aa46..f7442050ba5 100644 --- a/packages/ui/src/mosaic/text.ts +++ b/packages/ui/src/mosaic/text.ts @@ -30,10 +30,10 @@ export const textStyles = variants({ 'body-4': style(theme => ({ ...theme.typography.body[4] })), }, color: { - default: style(theme => ({ color: theme.colors.white })), - muted: style(theme => ({ color: theme.colors.gray[500] })), - subtle: style(theme => ({ color: theme.colors.gray[400] })), - accent: style(theme => ({ color: theme.colors.purple[400] })), + default: style(theme => ({ color: theme.colors.primary })), + muted: style(theme => ({ color: theme.colors.secondary })), + subtle: style(theme => ({ color: theme.colors.dimmed })), + accent: style(theme => ({ color: theme.colors.brand })), }, font: { sans: style(theme => ({ fontFamily: theme.fontFamilies.sans })), diff --git a/packages/ui/src/mosaic/theme.ts b/packages/ui/src/mosaic/theme.ts index e0cd8adb884..bbc3d622e46 100644 --- a/packages/ui/src/mosaic/theme.ts +++ b/packages/ui/src/mosaic/theme.ts @@ -254,6 +254,15 @@ const baseTheme = { blue, white: '#fff', black: '#000', + // Semantic tokens (using dark palette values) + primary: gray[100], + secondary: gray[500], + dimmed: gray[800], + positive: green[400], + negative: red[600], + warning: orange[500], + brand: purple[600], + info: blue[600], }, typography: { label, From 5823d4159e083740d248b1d2401f83abbd2c2883 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 3 Feb 2026 15:58:11 -0500 Subject: [PATCH 3/5] add empty changeset --- .changeset/sharp-pugs-sing.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/sharp-pugs-sing.md diff --git a/.changeset/sharp-pugs-sing.md b/.changeset/sharp-pugs-sing.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/sharp-pugs-sing.md @@ -0,0 +1,2 @@ +--- +--- From a186868b782779bbb64336dd43ea2593f6aad34a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 3 Feb 2026 16:02:42 -0500 Subject: [PATCH 4/5] barrel exports bad --- packages/ui/src/mosaic/index.ts | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 packages/ui/src/mosaic/index.ts diff --git a/packages/ui/src/mosaic/index.ts b/packages/ui/src/mosaic/index.ts deleted file mode 100644 index 4f4209f7c55..00000000000 --- a/packages/ui/src/mosaic/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { variants } from './variants'; -export { buttonStyles } from './button'; -export { MosaicThemeProvider, useMosaicTheme } from './theme-provider'; -export { mosaicTheme, type MosaicTheme } from './theme'; From b5508c0fd7cf11b934b0f21736ddd58d885897fe Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 4 Feb 2026 12:52:09 -0500 Subject: [PATCH 5/5] mosaic -> ceramic --- packages/ui/src/{mosaic => ceramic}/button.ts | 0 packages/ui/src/{mosaic => ceramic}/text.ts | 0 packages/ui/src/ceramic/theme-provider.tsx | 19 +++++++++++++++ packages/ui/src/{mosaic => ceramic}/theme.ts | 4 ++-- .../src/{mosaic => ceramic}/variants.test.ts | 24 +++++++++---------- .../ui/src/{mosaic => ceramic}/variants.ts | 18 +++++++------- .../{mosaic => ceramic}/variants.type.test.ts | 8 +++---- packages/ui/src/mosaic/theme-provider.tsx | 19 --------------- 8 files changed, 46 insertions(+), 46 deletions(-) rename packages/ui/src/{mosaic => ceramic}/button.ts (100%) rename packages/ui/src/{mosaic => ceramic}/text.ts (100%) create mode 100644 packages/ui/src/ceramic/theme-provider.tsx rename packages/ui/src/{mosaic => ceramic}/theme.ts (98%) rename packages/ui/src/{mosaic => ceramic}/variants.test.ts (94%) rename packages/ui/src/{mosaic => ceramic}/variants.ts (90%) rename packages/ui/src/{mosaic => ceramic}/variants.type.test.ts (96%) delete mode 100644 packages/ui/src/mosaic/theme-provider.tsx diff --git a/packages/ui/src/mosaic/button.ts b/packages/ui/src/ceramic/button.ts similarity index 100% rename from packages/ui/src/mosaic/button.ts rename to packages/ui/src/ceramic/button.ts diff --git a/packages/ui/src/mosaic/text.ts b/packages/ui/src/ceramic/text.ts similarity index 100% rename from packages/ui/src/mosaic/text.ts rename to packages/ui/src/ceramic/text.ts diff --git a/packages/ui/src/ceramic/theme-provider.tsx b/packages/ui/src/ceramic/theme-provider.tsx new file mode 100644 index 00000000000..ddebc749b9c --- /dev/null +++ b/packages/ui/src/ceramic/theme-provider.tsx @@ -0,0 +1,19 @@ +// eslint-disable-next-line no-restricted-imports +import createCache from '@emotion/cache'; +// eslint-disable-next-line no-restricted-imports +import { CacheProvider, ThemeContext } from '@emotion/react'; +import React from 'react'; + +import { type CeramicTheme, ceramicTheme } from './theme'; + +const ceramicCache = createCache({ key: 'ceramic' }); + +export const CeramicThemeProvider = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ); +}; + +export const useCeramicTheme = () => React.useContext(ThemeContext) as CeramicTheme; diff --git a/packages/ui/src/mosaic/theme.ts b/packages/ui/src/ceramic/theme.ts similarity index 98% rename from packages/ui/src/mosaic/theme.ts rename to packages/ui/src/ceramic/theme.ts index bbc3d622e46..4a25a2a087c 100644 --- a/packages/ui/src/mosaic/theme.ts +++ b/packages/ui/src/ceramic/theme.ts @@ -284,7 +284,7 @@ const fontRendering = { textSizeAdjust: '100%', } as const; -export const mosaicTheme = { +export const ceramicTheme = { ...baseTheme, focusRing: () => ({ outline: `2px solid ${baseTheme.colors.purple[700]}`, @@ -293,4 +293,4 @@ export const mosaicTheme = { fontRendering, } as const; -export type MosaicTheme = typeof mosaicTheme; +export type CeramicTheme = typeof ceramicTheme; diff --git a/packages/ui/src/mosaic/variants.test.ts b/packages/ui/src/ceramic/variants.test.ts similarity index 94% rename from packages/ui/src/mosaic/variants.test.ts rename to packages/ui/src/ceramic/variants.test.ts index 32d894a769c..65a9b136f93 100644 --- a/packages/ui/src/mosaic/variants.test.ts +++ b/packages/ui/src/ceramic/variants.test.ts @@ -2,13 +2,13 @@ import type { Interpolation, Theme } from '@emotion/react'; import { describe, expect, it } from 'vitest'; -import { mosaicTheme } from './theme'; +import { ceramicTheme } from './theme'; import { style, variants } from './variants'; // Helper to extract the CSS object from Interpolation function getStyles(interpolation: Interpolation): Record { if (typeof interpolation === 'function') { - return interpolation(mosaicTheme as unknown as Theme) as Record; + return interpolation(ceramicTheme as unknown as Theme) as Record; } if (interpolation == null) { return {}; @@ -38,8 +38,8 @@ describe('variants', () => { const result = getStyles(buttonStyles()); expect(result).toEqual({ - padding: mosaicTheme.spacing[4], - color: mosaicTheme.colors.white, + padding: ceramicTheme.spacing[4], + color: ceramicTheme.colors.white, }); }); @@ -76,8 +76,8 @@ describe('variants', () => { const result = getStyles(buttonStyles({ variant: 'primary' })); expect(result).toEqual({ - background: mosaicTheme.colors.purple[700], - color: mosaicTheme.colors.white, + background: ceramicTheme.colors.purple[700], + color: ceramicTheme.colors.white, }); }); @@ -230,7 +230,7 @@ describe('variants', () => { expect(result).toEqual({ fontSize: '0.75rem', background: 'blue', - border: `1px solid ${mosaicTheme.colors.purple[700]}`, + border: `1px solid ${ceramicTheme.colors.purple[700]}`, }); }); @@ -360,21 +360,21 @@ describe('variants', () => { describe('style', () => { it('returns the function unchanged', () => { - const fn = (theme: typeof mosaicTheme) => ({ color: theme.colors.white }); + const fn = (theme: typeof ceramicTheme) => ({ color: theme.colors.white }); const result = style(fn); expect(result).toBe(fn); }); - it('provides MosaicTheme typing', () => { + it('provides CeramicTheme typing', () => { const fn = style(theme => ({ color: theme.colors.white, padding: theme.spacing[4], })); - const result = fn(mosaicTheme); + const result = fn(ceramicTheme); expect(result).toEqual({ - color: mosaicTheme.colors.white, - padding: mosaicTheme.spacing[4], + color: ceramicTheme.colors.white, + padding: ceramicTheme.spacing[4], }); }); }); diff --git a/packages/ui/src/mosaic/variants.ts b/packages/ui/src/ceramic/variants.ts similarity index 90% rename from packages/ui/src/mosaic/variants.ts rename to packages/ui/src/ceramic/variants.ts index 45fb2623c6f..99a75721dc7 100644 --- a/packages/ui/src/mosaic/variants.ts +++ b/packages/ui/src/ceramic/variants.ts @@ -2,11 +2,11 @@ import { fastDeepMergeAndReplace } from '@clerk/shared/utils'; // eslint-disable-next-line no-restricted-imports import type { Interpolation, Theme } from '@emotion/react'; -import { type MosaicTheme } from './theme'; +import { type CeramicTheme } from './theme'; type CSSObject = Record; -// StyleFunction uses MosaicTheme to provide proper typing for theme parameter -type StyleFunction = (theme: MosaicTheme) => CSSObject; +// StyleFunction uses CeramicTheme to provide proper typing for theme parameter +type StyleFunction = (theme: CeramicTheme) => CSSObject; type StyleRule = CSSObject | StyleFunction; // Convert string literal "true" | "false" to boolean (CVA's StringToBoolean) @@ -53,7 +53,7 @@ type VariantsConfig = { }; /** - * Identity function that provides MosaicTheme typing for style functions. + * Identity function that provides CeramicTheme typing for style functions. * Use this to get autocomplete and type checking for theme properties. * * @example @@ -70,7 +70,7 @@ type VariantsConfig = { */ export const style = (fn: StyleFunction): StyleFunction => fn; -function resolveStyleRule(rule: StyleRule | undefined, theme: MosaicTheme): CSSObject { +function resolveStyleRule(rule: StyleRule | undefined, theme: CeramicTheme): CSSObject { if (!rule) { return {}; } @@ -145,11 +145,11 @@ export function variants(config: VariantsConfig) { return (props: ConfigVariants = {}): Interpolation => { return ((theme: Theme) => { - const mosaicTheme = theme as unknown as MosaicTheme; + const ceramicTheme = theme as unknown as CeramicTheme; const computedStyles: CSSObject = {}; const variantDefs = variantDefinitions as Record>; - const baseStyles = resolveStyleRule(base, mosaicTheme); + const baseStyles = resolveStyleRule(base, ceramicTheme); if (baseStyles && typeof baseStyles === 'object') { fastDeepMergeAndReplace(baseStyles, computedStyles); } @@ -165,14 +165,14 @@ export function variants(config: VariantsConfig) { const variantDef = variantDefs[variantKey]; const normalizedValue = normalizeVariantValue(variantValue); if (variantDef && normalizedValue in variantDef) { - const variantStyle = resolveStyleRule(variantDef[normalizedValue], mosaicTheme); + const variantStyle = resolveStyleRule(variantDef[normalizedValue], ceramicTheme); fastDeepMergeAndReplace(variantStyle, computedStyles); } } for (const compoundVariant of compoundVariants) { if (conditionMatches(compoundVariant, variantsToApply)) { - const compoundStyles = resolveStyleRule(compoundVariant.styles, mosaicTheme); + const compoundStyles = resolveStyleRule(compoundVariant.styles, ceramicTheme); fastDeepMergeAndReplace(compoundStyles, computedStyles); } } diff --git a/packages/ui/src/mosaic/variants.type.test.ts b/packages/ui/src/ceramic/variants.type.test.ts similarity index 96% rename from packages/ui/src/mosaic/variants.type.test.ts rename to packages/ui/src/ceramic/variants.type.test.ts index b8a7265f952..44c0bd5da36 100644 --- a/packages/ui/src/mosaic/variants.type.test.ts +++ b/packages/ui/src/ceramic/variants.type.test.ts @@ -1,6 +1,6 @@ import { describe, expectTypeOf, it } from 'vitest'; -import type { MosaicTheme } from './theme'; +import type { CeramicTheme } from './theme'; import { style, variants } from './variants'; describe('variants type tests', () => { @@ -114,14 +114,14 @@ describe('variants type tests', () => { }); describe('style helper', () => { - it('provides MosaicTheme typing for theme parameter', () => { + it('provides CeramicTheme typing for theme parameter', () => { const fn = style(theme => { - // Theme parameter should be typed as MosaicTheme + // Theme parameter should be typed as CeramicTheme return { color: theme.colors.white }; }); expectTypeOf(fn).toBeFunction(); - expectTypeOf[0]>().toEqualTypeOf(); + expectTypeOf[0]>().toEqualTypeOf(); }); it('returns StyleFunction type', () => { diff --git a/packages/ui/src/mosaic/theme-provider.tsx b/packages/ui/src/mosaic/theme-provider.tsx deleted file mode 100644 index f8b25690e5f..00000000000 --- a/packages/ui/src/mosaic/theme-provider.tsx +++ /dev/null @@ -1,19 +0,0 @@ -// eslint-disable-next-line no-restricted-imports -import createCache from '@emotion/cache'; -// eslint-disable-next-line no-restricted-imports -import { CacheProvider, ThemeContext } from '@emotion/react'; -import React from 'react'; - -import { type MosaicTheme, mosaicTheme } from './theme'; - -const mosaicCache = createCache({ key: 'mosaic' }); - -export const MosaicThemeProvider = ({ children }: { children: React.ReactNode }) => { - return ( - - {children} - - ); -}; - -export const useMosaicTheme = () => React.useContext(ThemeContext) as MosaicTheme;