diff --git a/.changeset/provider-wonju.md b/.changeset/provider-wonju.md new file mode 100644 index 00000000..db4bcd72 --- /dev/null +++ b/.changeset/provider-wonju.md @@ -0,0 +1,13 @@ +--- +"@sipe-team/theme": major +"@sipe-team/tokens": minor +--- + +Replace brand-color ThemeProvider with light/dark mode toggle and align design tokens with SSOT. + +- `ThemeProvider` now applies themes via `data-theme` attribute instead of `assignInlineVars` +- `theme` prop changed from brand-color objects to `'light' | 'dark'` string union +- `ThemeMode` type is now exported from `@sipe-team/tokens` +- VE contract structure for `color`, `spacing`, and `radius` reorganized into semantic token hierarchy +- Token values in `themes.css.ts` now reference Style Dictionary CSS variables instead of hardcoded JS constants +- Removed `@vanilla-extract/dynamic` dependency from `@sipe-team/theme` diff --git a/.claude/settings.json b/.claude/settings.json index f008a202..55a8b1fc 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -12,12 +12,12 @@ "Bash(ls*)", "Edit", "Write", - "Read" + "Read", + "Read(./packages/tokens/dist/**)" ], "ask": ["Bash(git push*)", "Bash(git commit*)", "Bash(git reset*)", "Bash(rm *)"], "deny": [ "Read(./node_modules/)", - "Read(./dist/)", "Read(./storybook-static/)", "Read(./www/build/)", "Read(./www/.docusaurus/)", diff --git a/packages/theme/package.json b/packages/theme/package.json index 7b3a13ee..09c2d896 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -35,7 +35,6 @@ "vitest": "catalog:" }, "dependencies": { - "@vanilla-extract/dynamic": "catalog:", "@sipe-team/tokens": "workspace:*" }, "publishConfig": { diff --git a/packages/theme/src/ThemeProvider.stories.tsx b/packages/theme/src/ThemeProvider.stories.tsx deleted file mode 100644 index 3a8f9057..00000000 --- a/packages/theme/src/ThemeProvider.stories.tsx +++ /dev/null @@ -1,461 +0,0 @@ -import { type ThemeColor, themeColor } from '@sipe-team/tokens'; - -import type { Meta, StoryObj } from '@storybook/react'; - -import { ThemeProvider, useTheme } from './ThemeProvider'; - -const meta = { - title: 'Components/ThemeProvider', - component: ThemeProvider, - parameters: { - layout: 'centered', - }, - argTypes: { - theme: { - control: { type: 'select' }, - options: ['1st', '2nd', '3rd', '4th'], - description: 'Select theme variant', - mapping: { - '1st': themeColor['1st'], - '2nd': themeColor['2nd'], - '3rd': themeColor['3rd'], - '4th': themeColor['4th'], - }, - }, - }, - decorators: [ - (Story) => ( -
- -
- ), - ], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -const ThemeDisplay = () => { - const { theme: currentTheme } = useTheme(); - - return ( - -
-

Current Theme: {currentTheme.primary}

- -
-

Theme Preview

-
-

- This is regular text using the theme's text color and medium font size. -

-

- This is primary-colored text with large font size and bold weight. -

-
- This is a primary background container -
-
- This is a secondary background container -
-
- This is a gradient background container -
-
-
-
-
- ); -}; - -export const Default: Story = { - args: { - theme: themeColor['4th'], - children: , - }, - render: (args) => { - return ( - - - - ); - }, -}; - -const NestedThemeExample = () => { - return ( -
-

Nested Theme Override Example

- - {/* Parent Theme Container */} - -
-

Parent Component (Theme 1st)

-

This is the parent component using the 1st theme.

- -
- Parent theme primary color container -
- - {/* Nested Child Theme Container */} - -
-

- Child Component (Theme 3rd - Overridden) -

-

- This child component overrides the parent theme with the 3rd theme. -

- -
- Child theme secondary color container -
- - {/* Deeply Nested Component */} - -
-
- Grandchild Component (Theme 4th) -
-

- This deeply nested component uses the 4th theme. -

- -
- Grandchild primary container -
-
-
-
-
-
-
-
- ); -}; - -export const NestedThemeOverride: Story = { - args: { - theme: themeColor['4th'], - children: , - }, - render: () => , - parameters: { - docs: { - description: { - story: - 'This example demonstrates how themes can be overridden in nested components. Each ThemeProvider creates a new theme context that overrides its parent.', - }, - }, - }, -}; - -const CustomThemeExample = () => { - // Custom theme object - const customTheme: ThemeColor = { - primary: '#ff6b6b', - secondary: '#4ecdc4', - background: '#f8f9fa', - text: '#343a40', - gradient: 'linear-gradient(135deg, #ff6b6b 0%, #4ecdc4 100%)', - }; - - return ( -
-

Custom Theme Injection Example

- - {/* Custom Theme Container */} - -
-

Custom Theme Component

-

- This component uses a completely custom theme with custom colors: -

- -
-
- Primary: #ff6b6b -
-
- Secondary: #4ecdc4 -
-
- -
- Custom Gradient Background -
- - {/* Nested with predefined theme */} - -
-

- Nested Predefined Theme (2nd) -

-

- This shows how you can nest predefined themes within custom themes. -

- -
- Predefined theme container -
-
-
-
-
- - {/* Multiple Custom Themes Side by Side */} -
- -
-

Purple Theme

-
- Custom Purple -
-
-
- - -
-

Green Theme

-
- Custom Green -
-
-
-
-
- ); -}; - -export const CustomThemeInjection: Story = { - args: { - theme: themeColor['4th'], - children: , - }, - render: () => , - parameters: { - docs: { - description: { - story: - 'This example demonstrates how to inject completely custom theme objects. You can create custom themes with any color values and use them alongside predefined themes.', - }, - }, - }, -}; diff --git a/packages/theme/src/ThemeProvider.test.tsx b/packages/theme/src/ThemeProvider.test.tsx index c187458c..4e5fb98b 100644 --- a/packages/theme/src/ThemeProvider.test.tsx +++ b/packages/theme/src/ThemeProvider.test.tsx @@ -1,7 +1,5 @@ -import { type ThemeColor, themeColor } from '@sipe-team/tokens'; - import { act, render, screen } from '@testing-library/react'; -import { describe, expect, it, test } from 'vitest'; +import { describe, expect, test } from 'vitest'; import { ThemeProvider, useTheme } from './ThemeProvider'; @@ -10,18 +8,12 @@ const TestComponent = () => { return (
- {theme.primary} - - - -
); @@ -29,49 +21,35 @@ const TestComponent = () => { const ComponentWithoutProvider = () => { const { theme } = useTheme(); - return
{theme.primary}
; + return
{theme}
; }; describe('ThemeProvider', () => { - test('sets 4th generation theme as default', () => { - render( + test('defaults to dark mode', () => { + const { container } = render( , ); - expect(screen.getByTestId('current-theme')).toHaveTextContent(themeColor['4th'].primary); - }); - - test('sets the theme to the provided initial theme prop', () => { - render( - - - , - ); - - expect(screen.getByTestId('current-theme')).toHaveTextContent(themeColor['2nd'].primary); + const themeDiv = container.firstChild as HTMLElement; + expect(themeDiv).toHaveAttribute('data-theme', 'dark'); + expect(screen.getByTestId('current-theme')).toHaveTextContent('dark'); }); - test('accepts custom theme color objects', () => { - const customTheme: ThemeColor = { - primary: '#ff0000', - secondary: '#00ff00', - background: '#0000ff', - text: '#ffffff', - gradient: 'linear-gradient(45deg, #ff0000 0%, #00ff00 100%)', - }; - - render( - + test('applies provided initial theme', () => { + const { container } = render( + , ); - expect(screen.getByTestId('current-theme')).toHaveTextContent(customTheme.primary); + const themeDiv = container.firstChild as HTMLElement; + expect(themeDiv).toHaveAttribute('data-theme', 'light'); + expect(screen.getByTestId('current-theme')).toHaveTextContent('light'); }); - test('container div has display: contents style', () => { + test('container has display: contents style', () => { const { container } = render( @@ -82,96 +60,41 @@ describe('ThemeProvider', () => { expect(themeContainer).toHaveStyle({ display: 'contents' }); }); - test('can change theme through setTheme', async () => { - render( + test('setTheme updates data-theme attribute', async () => { + const { container } = render( , ); - const currentTheme = screen.getByTestId('current-theme'); - const set2ndButton = screen.getByTestId('set-2nd'); - - expect(currentTheme).toHaveTextContent(themeColor['4th'].primary); + const themeDiv = container.firstChild as HTMLElement; + expect(themeDiv).toHaveAttribute('data-theme', 'dark'); await act(async () => { - set2ndButton.click(); + screen.getByTestId('set-light').click(); }); - expect(currentTheme).toHaveTextContent(themeColor['2nd'].primary); + expect(themeDiv).toHaveAttribute('data-theme', 'light'); + expect(screen.getByTestId('current-theme')).toHaveTextContent('light'); }); - test('theme updates when initial theme changes', () => { - const { rerender } = render( - + test('theme prop change updates data-theme attribute', () => { + const { rerender, container } = render( + , ); - expect(screen.getByTestId('current-theme')).toHaveTextContent(themeColor['1st'].primary); + const themeDiv = container.firstChild as HTMLElement; + expect(themeDiv).toHaveAttribute('data-theme', 'dark'); rerender( - + , ); - expect(screen.getByTestId('current-theme')).toHaveTextContent(themeColor['3rd'].primary); - }); - - describe('all theme types are set correctly', () => { - const themes = [ - { name: '1st', theme: themeColor['1st'] }, - { name: '2nd', theme: themeColor['2nd'] }, - { name: '3rd', theme: themeColor['3rd'] }, - { name: '4th', theme: themeColor['4th'] }, - ]; - - it.each(themes)('theme "$name" is set correctly', ({ theme }) => { - render( - - - , - ); - - const currentTheme = screen.getByTestId('current-theme'); - expect(currentTheme).toHaveTextContent(theme.primary); - }); - }); - - describe('theme change functionality tests', () => { - const themeChangeTests = [ - { from: themeColor['4th'], to: themeColor['1st'], buttonTestId: 'set-1st' }, - { from: themeColor['1st'], to: themeColor['2nd'], buttonTestId: 'set-2nd' }, - { from: themeColor['2nd'], to: themeColor['3rd'], buttonTestId: 'set-3rd' }, - { from: themeColor['3rd'], to: themeColor['4th'], buttonTestId: 'set-4th' }, - ]; - - it.each(themeChangeTests)('can change theme from $from.primary to $to.primary', async ({ - from, - to, - buttonTestId, - }) => { - render( - - - , - ); - - const currentTheme = screen.getByTestId('current-theme'); - const changeButton = screen.getByTestId(buttonTestId); - - // Check initial state - expect(currentTheme).toHaveTextContent(from.primary); - - // Change theme - await act(async () => { - changeButton.click(); - }); - - // Check changed state - expect(currentTheme).toHaveTextContent(to.primary); - }); + expect(themeDiv).toHaveAttribute('data-theme', 'light'); }); test('children are rendered correctly', () => { @@ -187,8 +110,7 @@ describe('ThemeProvider', () => { }); describe('useTheme hook', () => { - test('throws an error when used outside of ThemeProvider', () => { - // Mock console.error to hide console errors + test('throws when used outside ThemeProvider', () => { const originalError = console.error; console.error = () => {}; @@ -196,21 +118,18 @@ describe('useTheme hook', () => { render(); }).toThrow('useTheme must be used within a ThemeProvider'); - // Restore console.error console.error = originalError; }); - test('returns correct context values when used inside ThemeProvider', () => { + test('returns correct theme and setTheme', () => { render( - + , ); - expect(screen.getByTestId('current-theme')).toHaveTextContent(themeColor['2nd'].primary); - expect(screen.getByTestId('set-1st')).toBeInTheDocument(); - expect(screen.getByTestId('set-2nd')).toBeInTheDocument(); - expect(screen.getByTestId('set-3rd')).toBeInTheDocument(); - expect(screen.getByTestId('set-4th')).toBeInTheDocument(); + expect(screen.getByTestId('current-theme')).toHaveTextContent('dark'); + expect(screen.getByTestId('set-light')).toBeInTheDocument(); + expect(screen.getByTestId('set-dark')).toBeInTheDocument(); }); }); diff --git a/packages/theme/src/ThemeProvider.tsx b/packages/theme/src/ThemeProvider.tsx index bd844775..97f2e925 100644 --- a/packages/theme/src/ThemeProvider.tsx +++ b/packages/theme/src/ThemeProvider.tsx @@ -1,13 +1,11 @@ import type React from 'react'; import { createContext, useContext, useEffect, useMemo, useState } from 'react'; -import { type ThemeColor, themeColor, vars } from '@sipe-team/tokens'; - -import { assignInlineVars } from '@vanilla-extract/dynamic'; +import { type ThemeMode, vars } from '@sipe-team/tokens'; interface ThemeContextType { - theme: ThemeColor; - setTheme: (theme: ThemeColor) => void; + theme: ThemeMode; + setTheme: (theme: ThemeMode) => void; } const ThemeContext = createContext(undefined); @@ -22,11 +20,11 @@ export const useTheme = (): ThemeContextType => { interface ThemeProviderProps { children: React.ReactNode; - theme?: ThemeColor; + theme?: ThemeMode; } -export const ThemeProvider: React.FC = ({ children, theme: initialTheme = themeColor['4th'] }) => { - const [theme, setTheme] = useState(initialTheme); +export const ThemeProvider: React.FC = ({ children, theme: initialTheme = 'dark' }) => { + const [theme, setTheme] = useState(initialTheme); useEffect(() => { setTheme(initialTheme); @@ -40,15 +38,11 @@ export const ThemeProvider: React.FC = ({ children, theme: i [theme], ); - const themeVars = assignInlineVars(vars.color.accent, { - default: theme.primary, - hover: theme.secondary, - subtle: theme.background, - }); - return ( -
{children}
+
+ {children} +
); }; diff --git a/packages/tokens/src/colors/index.ts b/packages/tokens/src/colors/index.ts index 1bae1c0e..269a9424 100644 --- a/packages/tokens/src/colors/index.ts +++ b/packages/tokens/src/colors/index.ts @@ -1 +1,2 @@ +// TODO: colors.ts is superseded by tokens/tokens.json (SD SSOT). Remove in a follow-up PR. export * from './colors'; diff --git a/packages/tokens/src/theme/contract.css.ts b/packages/tokens/src/theme/contract.css.ts index e63e7caa..403fac7b 100644 --- a/packages/tokens/src/theme/contract.css.ts +++ b/packages/tokens/src/theme/contract.css.ts @@ -1,5 +1,8 @@ import { createGlobalThemeContract, globalLayer } from '@vanilla-extract/css'; +// TODO: dark is the default mode; light mode will be added later. +export type ThemeMode = 'light' | 'dark'; + export const themeLayer = globalLayer('theme'); export const vars = createGlobalThemeContract( @@ -111,8 +114,6 @@ export const vars = createGlobalThemeContract( xl: 'shadow-xl', '2xl': 'shadow-2xl', }, - mode: 'theme-mode', - theme: 'theme-name', }, (value) => `side-${value}`, ); diff --git a/packages/tokens/src/theme/themes.css.ts b/packages/tokens/src/theme/themes.css.ts index a0622c39..b994809b 100644 --- a/packages/tokens/src/theme/themes.css.ts +++ b/packages/tokens/src/theme/themes.css.ts @@ -1,52 +1,36 @@ import { createGlobalTheme } from '@vanilla-extract/css'; -import { brandColor, color, themeColor } from '../colors/colors'; -import { radius } from '../effects/radius'; import { shadows } from '../effects/shadows'; -import { spacing } from '../layout/spacing'; -import { fontSize, fontWeight, lineHeight } from '../typography/fonts'; import { themeLayer, vars } from './contract.css'; +import { cssVar, mapVars } from './utils'; const baseTheme = { '@layer': themeLayer, - spacing: { - component: { - xs: `${spacing[1]}px`, - sm: `${spacing[2]}px`, - md: `${spacing[3]}px`, - lg: `${spacing[4]}px`, - xl: `${spacing[6]}px`, - }, - layout: { - sm: `${spacing[8]}px`, - md: `${spacing[10]}px`, - lg: `${spacing[12]}px`, - xl: `${spacing[16]}px`, - }, - }, + spacing: mapVars(vars.spacing), typography: { - fontFamily: 'Pretendard, system-ui, sans-serif', + // typography contract values don't align with SD path-based naming — referenced manually + fontFamily: cssVar('typography-font-family-base'), fontSize: { - '050': `${fontSize[12]}px`, - '100': `${fontSize[14]}px`, - '200': `${fontSize[16]}px`, - '300': `${fontSize[18]}px`, - '400': `${fontSize[20]}px`, - '500': `${fontSize[24]}px`, - '600': `${fontSize[28]}px`, - '700': `${fontSize[32]}px`, - '800': `${fontSize[36]}px`, - '900': `${fontSize[48]}px`, + '050': cssVar('typography-font-size-12'), + '100': cssVar('typography-font-size-14'), + '200': cssVar('typography-font-size-16'), + '300': cssVar('typography-font-size-18'), + '400': cssVar('typography-font-size-20'), + '500': cssVar('typography-font-size-24'), + '600': cssVar('typography-font-size-28'), + '700': cssVar('typography-font-size-32'), + '800': cssVar('typography-font-size-36'), + '900': cssVar('typography-font-size-48'), }, lineHeight: { - regular: `${lineHeight.regular}`, - compact: `${lineHeight.compact}`, + regular: cssVar('typography-line-height-regular'), + compact: cssVar('typography-line-height-compact'), }, fontWeight: { - regular: `${fontWeight.regular}`, - medium: `${fontWeight.medium}`, - semiBold: `${fontWeight.semiBold}`, - bold: `${fontWeight.bold}`, + regular: cssVar('typography-font-weight-regular'), + medium: cssVar('typography-font-weight-medium'), + semiBold: cssVar('typography-font-weight-semi-bold'), + bold: cssVar('typography-font-weight-bold'), }, }, shadows: { @@ -57,113 +41,21 @@ const baseTheme = { xl: shadows.xl, '2xl': shadows['2xl'], }, - radius: { - component: { - sm: radius.sm, - md: radius.md, - lg: radius.lg, - xl: radius.xl, - full: radius.full, - }, - layout: { - sm: radius.md, - md: radius.lg, - lg: radius.xl, - }, - }, + radius: mapVars(vars.radius), }; -const darkBaseColor = { - background: { - base: color.gray950, - subtle: color.gray900, - muted: color.gray800, - }, - foreground: { - default: color.white, - subtle: color.gray400, - muted: color.gray500, - onAccent: color.white, - }, - border: { - default: color.gray700, - strong: color.gray500, - focus: color.blue400, - }, - status: { - success: { foreground: color.green400, background: color.green900, border: color.green700 }, - warning: { foreground: color.orange400, background: color.orange900, border: color.orange700 }, - danger: { foreground: color.red400, background: color.red900, border: color.red700 }, - info: { foreground: color.blue400, background: color.blue900, border: color.blue700 }, - }, -}; +const darkColor = mapVars(vars.color); +// :root — global dark fallback for contexts without an explicit data-theme attribute createGlobalTheme(':root', vars, { ...baseTheme, - color: { - ...darkBaseColor, - accent: { - default: brandColor.default, - hover: brandColor.hover, - subtle: brandColor.subtle, - }, - }, - mode: 'dark', - theme: 'default', + color: darkColor, }); -createGlobalTheme('[data-theme="1st"]', vars, { +// [data-theme="dark"] — allows a dark sub-region inside a future light-mode root +createGlobalTheme('[data-theme="dark"]', vars, { ...baseTheme, - color: { - ...darkBaseColor, - accent: { - default: themeColor['1st'].primary, - hover: themeColor['1st'].secondary, - subtle: color.green900, - }, - }, - mode: 'dark', - theme: '1st', + color: darkColor, }); -createGlobalTheme('[data-theme="2nd"]', vars, { - ...baseTheme, - color: { - ...darkBaseColor, - accent: { - default: themeColor['2nd'].primary, - hover: themeColor['2nd'].secondary, - subtle: color.teal900, - }, - }, - mode: 'dark', - theme: '2nd', -}); - -createGlobalTheme('[data-theme="3rd"]', vars, { - ...baseTheme, - color: { - ...darkBaseColor, - accent: { - default: themeColor['3rd'].primary, - hover: themeColor['3rd'].secondary, - subtle: color.cyan900, - }, - }, - mode: 'dark', - theme: '3rd', -}); - -createGlobalTheme('[data-theme="4th"]', vars, { - ...baseTheme, - color: { - ...darkBaseColor, - accent: { - default: themeColor['4th'].primary, - hover: themeColor['4th'].secondary, - subtle: color.pink900, - }, - }, - mode: 'dark', - theme: '4th', -}); +// TODO: dark is the default mode; light mode will be added later. diff --git a/packages/tokens/src/theme/utils.test.ts b/packages/tokens/src/theme/utils.test.ts new file mode 100644 index 00000000..99416780 --- /dev/null +++ b/packages/tokens/src/theme/utils.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { mapVars } from './utils'; + +describe('mapVars', () => { + it('strips side- prefix from a leaf string', () => { + expect(mapVars('var(--side-spacing-component-xs)')).toBe('var(--spacing-component-xs)'); + }); + + it('recursively transforms all leaves in a nested object', () => { + const input = { + component: { + xs: 'var(--side-spacing-component-xs)', + sm: 'var(--side-spacing-component-sm)', + }, + layout: { + md: 'var(--side-spacing-layout-md)', + }, + }; + + expect(mapVars(input)).toEqual({ + component: { + xs: 'var(--spacing-component-xs)', + sm: 'var(--spacing-component-sm)', + }, + layout: { + md: 'var(--spacing-layout-md)', + }, + }); + }); + + it('leaves strings without side- prefix unchanged', () => { + expect(mapVars('var(--color-background-base)')).toBe('var(--color-background-base)'); + }); +}); diff --git a/packages/tokens/src/theme/utils.ts b/packages/tokens/src/theme/utils.ts new file mode 100644 index 00000000..a1744f8f --- /dev/null +++ b/packages/tokens/src/theme/utils.ts @@ -0,0 +1,15 @@ +export type MapLeaves = T extends string ? string : { [K in keyof T]: MapLeaves }; + +export const cssVar = (token: string) => `var(--${token})`; + +/** + * Strips the `side-` prefix from every leaf value in a vars subtree, + * converting `'var(--side-*)'` references into SD CSS variable references (`'var(--*)'`). + * + * When the VE contract key and the SD token path don't match (e.g. typography), + * use cssVar() directly instead. + */ +export function mapVars(obj: T): MapLeaves { + if (typeof obj === 'string') return obj.replace('var(--side-', 'var(--') as MapLeaves; + return Object.fromEntries(Object.entries(obj as object).map(([k, v]) => [k, mapVars(v)])) as MapLeaves; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e702083f..f7619806 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1000,9 +1000,6 @@ importers: '@sipe-team/tokens': specifier: workspace:* version: link:../tokens - '@vanilla-extract/dynamic': - specifier: 'catalog:' - version: 2.1.5 devDependencies: '@testing-library/jest-dom': specifier: 'catalog:'