diff --git a/.changeset/settings-block.md b/.changeset/settings-block.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/settings-block.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index fb680d13c3c..679269b376d 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -42,6 +42,7 @@ const docModules: Record> = { icon: dynamic(() => import('../stories/icon.mdx')), menu: dynamic(() => import('../stories/menu.component.mdx')), popover: dynamic(() => import('../stories/popover.component.mdx')), + settings: dynamic(() => import('../stories/settings.mdx')), tabs: dynamic(() => import('../stories/tabs.component.mdx')), text: dynamic(() => import('../stories/text.mdx')), field: dynamic(() => import('../stories/field.component.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 60a834b5d98..6a69e33d0a8 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -106,6 +106,13 @@ import { ThemedScrollbar as ScrollAreaThemedScrollbar, } from '../stories/scroll-area.stories'; import { meta as selectMeta } from '../stories/select.stories'; +import { + ConnectedAccounts as SettingsConnectedAccounts, + Default as SettingsDefault, + Destructive as SettingsDestructive, + meta as settingsMeta, + MultipleEmailAndPhoneNumbers as SettingsMultipleEmailAndPhoneNumbers, +} from '../stories/settings.stories'; import { Default as TabsComponentDefault, meta as tabsComponentMeta } from '../stories/tabs.component.stories'; import { meta as tabsMeta } from '../stories/tabs.stories'; import { @@ -126,6 +133,13 @@ import { toSlug } from './slug'; import type { StoryModule } from './types'; const destructiveModule: StoryModule = { meta: destructiveMeta, Default: DestructiveDefault }; +const settingsModule: StoryModule = { + meta: settingsMeta, + Default: SettingsDefault, + MultipleEmailAndPhoneNumbers: SettingsMultipleEmailAndPhoneNumbers, + ConnectedAccounts: SettingsConnectedAccounts, + Destructive: SettingsDestructive, +}; const organizationProfileLeaveSectionModule: StoryModule = { meta: organizationProfileLeaveSectionMeta, Default: OrganizationProfileLeaveSectionDefault, @@ -281,6 +295,7 @@ export const registry: StoryModule[] = [ iconModule, menuComponentModule, popoverComponentModule, + settingsModule, tabsComponentModule, textModule, fieldModule, diff --git a/packages/swingset/src/stories/settings.mdx b/packages/swingset/src/stories/settings.mdx new file mode 100644 index 00000000000..dd3c149f737 --- /dev/null +++ b/packages/swingset/src/stories/settings.mdx @@ -0,0 +1,74 @@ +import * as SettingsStories from './settings.stories'; + +# Settings + +A settings component that carries the canonical section, surface, row grouping, and item layout. Use it for product settings while composing each item's media, content, and actions as needed. + +## Usage + +```tsx +import { Settings } from '@clerk/ui/mosaic/components/settings'; + + + Account + + + + {icon} + + Name + Shown throughout the application. + + {control} + + + + +``` + +`Settings.Group` owns the outlined surface. Its direct `Settings.Row` children own separation and may stack one or more `Settings.Item` entries. Each item lays out optional media, flexible content, and trailing actions. `Settings.Media` accepts `md` and `lg` sizes. + +Use `Settings.Items` for a nested value list beneath a row's header item. The row keeps an 8px header-to-list gap, nested items stack without gaps at a 44px minimum height, and their content receives 12px vertical padding. + +Override `--cl-settings-items-gap` on `Settings.Root` or a specific `Settings.Row` to customize the space between the row header and its nested value list. It defaults to 8px. + +## Examples + + + +### Multiple email addresses and phone numbers + + + +### Connected accounts + + + +### Destructive setting + + diff --git a/packages/swingset/src/stories/settings.stories.tsx b/packages/swingset/src/stories/settings.stories.tsx new file mode 100644 index 00000000000..11346f9593b --- /dev/null +++ b/packages/swingset/src/stories/settings.stories.tsx @@ -0,0 +1,355 @@ +/** @jsxImportSource @emotion/react */ +import { Avatar } from '@clerk/ui/mosaic/components/avatar'; +import { Badge } from '@clerk/ui/mosaic/components/badge'; +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Icon } from '@clerk/ui/mosaic/components/icon'; +import { Settings } from '@clerk/ui/mosaic/components/settings'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './settings.stories?raw'; + +const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`; + +function ProviderIcon({ provider }: { provider: string }) { + return ( + + ); +} + +export const meta: StoryMeta = { + group: 'Components', + title: 'Settings', + source: 'packages/ui/src/mosaic/components/settings/settings.tsx', + styleEngine: 'stylex', +}; + +export function Default() { + return ( + + Profile + + + + + Profile picture + PNG or JPEG, Recommended size 1:1, up to 10MB. + + + + + PB + + + + + + + + + Name + Preston Booth + + + + + + + + + + + Username + Prestonb.xyz + + + + + + + + + + + Email + item1@clerk.dev + + + + + + + + + ); +} + +export function MultipleEmailAndPhoneNumbers() { + return ( + + Profile + + + + + Profile picture + PNG or JPEG, Recommended size 1:1, up to 10MB. + + + + + PB + + + + + + + + + Name + Preston Booth + + + + + + + + + + + Username + Prestonb.xyz + + + + + + + + + + + Email + + + + + + + + + + item1@clerk.dev + Primary + + + + + + + + + item2@clerk.dev + + + + + + + + + + + + Phone + + + + + + + + + + +1 801-888-8181 + Primary + + + + + + + + + + + ); +} + +export function ConnectedAccounts() { + return ( + + Connected accounts + + + + + + + + Google + test@google.com + + + + + + + + + + + + + Apple + + + + + + + + + ); +} + +export function Destructive() { + return ( + + Danger zone + + + + + Delete account + + Permanently delete this profile and all its data. This cannot be undone. + + + + + + + + + + ); +} diff --git a/packages/ui/src/mosaic/components/reset.test.tsx b/packages/ui/src/mosaic/components/reset.test.tsx index 861c05364db..5f84c5de704 100644 --- a/packages/ui/src/mosaic/components/reset.test.tsx +++ b/packages/ui/src/mosaic/components/reset.test.tsx @@ -13,6 +13,7 @@ import { Icon } from './icon'; import { Input } from './input'; import { Item } from './item'; import { reset } from './reset.styles'; +import { Settings } from './settings'; import { Text } from './text'; // StyleX generates the same atom for the same property+value across separate `create` calls, so a @@ -60,6 +61,17 @@ const cases: Array<[string, React.ReactElement]> = [ ['Item', ], ['Item.Group', ], ['Item.Separator', ], + ['Settings', ], + ['Settings.Title', Account], + ['Settings.Group', ], + ['Settings.Row', ], + ['Settings.Items', ], + ['Settings.Item', ], + ['Settings.Media', ], + ['Settings.Content', ], + ['Settings.Label', ], + ['Settings.Description', ], + ['Settings.Actions', ], ['Text', Body copy], ]; diff --git a/packages/ui/src/mosaic/components/settings/index.ts b/packages/ui/src/mosaic/components/settings/index.ts new file mode 100644 index 00000000000..e259553aa4d --- /dev/null +++ b/packages/ui/src/mosaic/components/settings/index.ts @@ -0,0 +1,15 @@ +export { Settings, settingsVars } from './settings'; +export type { + SettingsActionsProps, + SettingsContentProps, + SettingsDescriptionProps, + SettingsGroupProps, + SettingsItemProps, + SettingsItemsProps, + SettingsLabelProps, + SettingsMediaProps, + SettingsMediaSize, + SettingsRootProps, + SettingsRowProps, + SettingsTitleProps, +} from './settings'; diff --git a/packages/ui/src/mosaic/components/settings/settings.markers.stylex.ts b/packages/ui/src/mosaic/components/settings/settings.markers.stylex.ts new file mode 100644 index 00000000000..ce4b0c07ebd --- /dev/null +++ b/packages/ui/src/mosaic/components/settings/settings.markers.stylex.ts @@ -0,0 +1,3 @@ +import * as stylex from '@stylexjs/stylex'; + +export const settingsItemsMarker = stylex.defineMarker(); diff --git a/packages/ui/src/mosaic/components/settings/settings.test.tsx b/packages/ui/src/mosaic/components/settings/settings.test.tsx new file mode 100644 index 00000000000..6c239b70a06 --- /dev/null +++ b/packages/ui/src/mosaic/components/settings/settings.test.tsx @@ -0,0 +1,146 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { colorVars } from '../../tokens.stylex'; +import { Settings, settingsVars } from './settings'; + +describe('Settings', () => { + it('aliases its background to the global background token', () => { + expect(colorVars).toMatchObject({ + '--cl-color-background': 'var(--cl-color-background)', + }); + expect(settingsVars).toMatchObject({ + '--cl-settings-background': 'var(--cl-settings-background)', + '--cl-settings-items-gap': 'var(--cl-settings-items-gap)', + }); + }); + + it('renders an accessible settings section and every compound part', () => { + render( + + Account + + + + + Icon + + + Name + Shown throughout the application. + + Control + + + + , + ); + + expect(screen.getByRole('region', { name: 'Account' })).toHaveClass('cl-settings'); + expect(screen.getByRole('heading', { level: 4, name: 'Account' })).toHaveClass('cl-settings-title'); + expect(screen.getByTestId('group')).toHaveClass('cl-settings-group'); + expect(screen.getByTestId('row')).toHaveClass('cl-settings-row'); + expect(screen.getByTestId('item')).toHaveClass('cl-settings-item'); + expect(screen.getByTestId('media')).toHaveClass('cl-settings-media'); + expect(screen.getByTestId('media')).toHaveAttribute('data-size', 'lg'); + expect(screen.getByTestId('content')).toHaveClass('cl-settings-content'); + expect(screen.getByTestId('label')).toHaveClass('cl-settings-label'); + expect(screen.getByTestId('description')).toHaveClass('cl-settings-description'); + expect(screen.getByTestId('actions')).toHaveClass('cl-settings-actions'); + }); + + it('supports an explicit accessible name', () => { + render( + + Account + + , + ); + + expect(screen.getByRole('region', { name: 'Account preferences' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 4, name: 'Account' })).toBeInTheDocument(); + }); + + it('composes multiple items in one row', () => { + render( + + Profile + + + + + Email + + Edit + + + + + ada@example.com + + More + + + + + , + ); + + expect(screen.getByText('Email')).toBeInTheDocument(); + expect(screen.getByText('ada@example.com')).toBeInTheDocument(); + expect(screen.getAllByText(/Edit|More/)).toHaveLength(2); + expect(screen.getByTestId('items')).toHaveClass('cl-settings-items'); + expect(screen.getByTestId('items')).toHaveAttribute('data-nested'); + expect(screen.getByTestId('nested-item')).toHaveAttribute('data-nested'); + expect(screen.getByTestId('nested-content')).toHaveAttribute('data-nested'); + }); + + it('lets consumer props win and forwards refs and custom elements', () => { + const rootRef = React.createRef(); + const groupRef = React.createRef(); + const itemRef = React.createRef(); + const contentRef = React.createRef(); + const actionsRef = React.createRef(); + + render( +
} + className='custom-root' + > + Account + + + + + Name + + + + + + , + ); + + expect(rootRef.current?.tagName).toBe('ARTICLE'); + expect(rootRef.current).toHaveClass('cl-settings', 'custom-root'); + expect(groupRef.current).toHaveClass('cl-settings-group', 'custom-group'); + expect(groupRef.current).toHaveStyle({ borderWidth: '2px' }); + expect(itemRef.current).toHaveClass('cl-settings-item'); + expect(itemRef.current).toHaveStyle({ minHeight: '80px' }); + expect(contentRef.current).toHaveClass('cl-settings-content'); + expect(screen.getByText('Name')).toHaveStyle({ color: 'rgb(255, 0, 0)' }); + expect(actionsRef.current).toHaveClass('cl-settings-actions'); + }); +}); diff --git a/packages/ui/src/mosaic/components/settings/settings.tsx b/packages/ui/src/mosaic/components/settings/settings.tsx new file mode 100644 index 00000000000..7573f55100e --- /dev/null +++ b/packages/ui/src/mosaic/components/settings/settings.tsx @@ -0,0 +1,365 @@ +import { useRender } from '@clerk/headless/utils'; +import { useSafeLayoutEffect } from '@clerk/shared/react'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicComponentProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; +import type { HeadingProps } from '../heading'; +import { Heading } from '../heading'; +import { reset } from '../reset.styles'; +import { settingsItemsMarker } from './settings.markers.stylex'; +import { settingsVars } from './settings.vars.stylex'; + +export { settingsVars } from './settings.vars.stylex'; + +export type SettingsRootProps = Omit, 'title'>; +export type SettingsTitleProps = Omit; +export type SettingsGroupProps = MosaicComponentProps<'div'>; +export type SettingsRowProps = MosaicComponentProps<'div'>; +export type SettingsItemsProps = MosaicComponentProps<'div'>; +export type SettingsItemProps = MosaicComponentProps<'div'>; +export type SettingsMediaSize = 'sm' | 'md' | 'lg'; +export type SettingsMediaProps = MosaicComponentProps<'div'> & { size?: SettingsMediaSize }; +export type SettingsContentProps = MosaicComponentProps<'div'>; +export type SettingsLabelProps = MosaicComponentProps<'div'>; +export type SettingsDescriptionProps = MosaicComponentProps<'div'>; +export type SettingsActionsProps = MosaicComponentProps<'div'>; + +/* eslint-disable @stylexjs/no-lookahead-selectors -- Mosaic's supported browsers include :has(); + the marker keeps this selector scoped to Settings.Items. */ +const styles = stylex.create({ + root: { + display: 'flex', + flexDirection: 'column', + rowGap: space['2'], + width: '100%', + }, + group: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-xl'], + borderStyle: 'solid', + borderWidth: '1px', + overflow: 'hidden', + backgroundColor: settingsVars['--cl-settings-background'], + width: '100%', + }, + row: { + marginInline: space['4'], + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: { + default: '1px', + ':first-child': '0px', + }, + display: 'flex', + flexDirection: 'column', + paddingBlockEnd: { + default: space['4'], + [stylex.when.descendant('[data-nested]', settingsItemsMarker)]: space['1'], + }, + paddingBlockStart: space['4'], + rowGap: settingsVars['--cl-settings-items-gap'], + width: 'auto', + }, + items: { + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + item: { + alignItems: 'center', + columnGap: space['3'], + display: 'flex', + flexWrap: 'nowrap', + justifyContent: 'space-between', + width: '100%', + }, + nestedItem: { + minHeight: space['11'], + }, + mediaBase: { + alignItems: 'center', + alignSelf: 'center', + aspectRatio: '1/1', + display: 'flex', + flexShrink: 0, + justifyContent: 'center', + }, + mediaSm: { + height: space['4'], + width: space['4'], + }, + mediaMd: { + height: space['6'], + width: space['6'], + }, + mediaLg: { + height: space['8'], + width: space['8'], + }, + content: { + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + justifyContent: 'center', + rowGap: space['0.5'], + minWidth: 0, + }, + nestedContent: { + paddingBlock: space['3'], + }, + label: { + color: colorVars['--cl-color-card-foreground'], + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + }, + description: { + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + textWrap: 'balance', + }, + actions: { + alignItems: 'center', + display: 'flex', + flexShrink: 0, + justifyContent: 'flex-end', + marginInlineStart: space['3'], + }, +}); +/* eslint-enable @stylexjs/no-lookahead-selectors */ + +const mediaSizes = { + sm: styles.mediaSm, + md: styles.mediaMd, + lg: styles.mediaLg, +}; + +const SettingsTitleContext = React.createContext> | null>(null); +const SettingsItemsContext = React.createContext(false); + +const Root = React.forwardRef(function SettingsRoot( + { render, className, style, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...rest }, + ref, +) { + const [titleIds, setTitleIds] = React.useState([]); + + const element = useRender({ + defaultTagName: 'section', + render, + ref, + props: { + ...mergeStyleProps(themeProps('settings'), stylex.props(reset.base, styles.root), className, style), + ...rest, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy ?? (ariaLabel ? undefined : titleIds.join(' ') || undefined), + }, + }); + + return {element}; +}); + +const Title = React.forwardRef(function SettingsTitle( + { id: idProp, render, className, style, ...rest }, + ref, +) { + const setTitleIds = React.useContext(SettingsTitleContext); + const generatedId = React.useId(); + const id = idProp ?? (setTitleIds ? `cl-settings-${generatedId}-title` : undefined); + + useSafeLayoutEffect(() => { + if (!id || !setTitleIds) { + return undefined; + } + + setTitleIds(ids => (ids.includes(id) ? ids : [...ids, id])); + return () => setTitleIds(ids => ids.filter(value => value !== id)); + }, [id, setTitleIds]); + + return ( +

)} + size='sm' + {...mergeStyleProps(themeProps('settings-title'), className, style)} + {...rest} + /> + ); +}); + +const Group = React.forwardRef(function SettingsGroup( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('settings-group'), stylex.props(reset.base, styles.group), className, style), + ...rest, + }, + }); +}); + +const Row = React.forwardRef(function SettingsRow( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('settings-row'), stylex.props(reset.base, styles.row), className, style), + ...rest, + }, + }); +}); + +const Items = React.forwardRef(function SettingsItems( + { render, className, style, ...rest }, + ref, +) { + const element = useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('settings-items', { nested: true }), + stylex.props(reset.base, styles.items, settingsItemsMarker), + className, + style, + ), + ...rest, + }, + }); + + return {element}; +}); + +const Item = React.forwardRef(function SettingsItem( + { render, className, style, ...rest }, + ref, +) { + const nested = React.useContext(SettingsItemsContext); + + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('settings-item', { nested }), + stylex.props(reset.base, styles.item, nested && styles.nestedItem), + className, + style, + ), + ...rest, + }, + }); +}); + +const Media = React.forwardRef(function SettingsMedia( + { size = 'md', render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('settings-media', { size }), + stylex.props(reset.base, styles.mediaBase, mediaSizes[size]), + className, + style, + ), + ...rest, + }, + }); +}); + +const Content = React.forwardRef(function SettingsContent( + { render, className, style, ...rest }, + ref, +) { + const nested = React.useContext(SettingsItemsContext); + + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('settings-content', { nested }), + stylex.props(reset.base, styles.content, nested && styles.nestedContent), + className, + style, + ), + ...rest, + }, + }); +}); + +const Label = React.forwardRef(function SettingsLabel( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('settings-label'), stylex.props(reset.base, styles.label), className, style), + ...rest, + }, + }); +}); + +const Description = React.forwardRef(function SettingsDescription( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('settings-description'), + stylex.props(reset.base, styles.description), + className, + style, + ), + ...rest, + }, + }); +}); + +const Actions = React.forwardRef(function SettingsActions( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('settings-actions'), stylex.props(reset.base, styles.actions), className, style), + ...rest, + }, + }); +}); + +/** + * A settings component that fixes section semantics, surface treatment, row grouping, + * and item layout while leaving each item's content composable. + */ +export const Settings = { Root, Title, Group, Row, Items, Item, Media, Content, Label, Description, Actions }; diff --git a/packages/ui/src/mosaic/components/settings/settings.vars.stylex.ts b/packages/ui/src/mosaic/components/settings/settings.vars.stylex.ts new file mode 100644 index 00000000000..fc417af7a38 --- /dev/null +++ b/packages/ui/src/mosaic/components/settings/settings.vars.stylex.ts @@ -0,0 +1,8 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, space } from '../../tokens.stylex'; + +export const settingsVars = stylex.defineVars({ + '--cl-settings-background': colorVars['--cl-color-background'], + '--cl-settings-items-gap': space['2'], +}); diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index adb03acb478..a02ac9439ca 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -34,6 +34,21 @@ export type { } from '../components/menu'; export { scrollAreaRoot, scrollAreaVars, scrollAreaViewport } from '../components/scroll-area'; export type { ScrollAreaGutter } from '../components/scroll-area'; +export { Settings, settingsVars } from '../components/settings'; +export type { + SettingsActionsProps, + SettingsContentProps, + SettingsDescriptionProps, + SettingsGroupProps, + SettingsItemProps, + SettingsItemsProps, + SettingsLabelProps, + SettingsMediaProps, + SettingsMediaSize, + SettingsRootProps, + SettingsRowProps, + SettingsTitleProps, +} from '../components/settings'; export { Spinner } from '../components/spinner'; export type { SpinnerProps } from '../components/spinner'; export { Text, TextContext } from '../components/text'; diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 5b9ea1d20bc..6d249a5b660 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -44,6 +44,8 @@ const colorDefaults = { '--cl-color-warning-foreground': 'oklch(0.985 0 0)', '--cl-color-warning-faded': 'light-dark(oklch(0.9799 0.0147 70.89), oklch(0.2725 0.0547 55.7))', + '--cl-color-background': 'light-dark(oklch(0.985 0 0), oklch(0.205 0 0))', + '--cl-color-card': 'light-dark(oklch(1 0 0), oklch(0.205 0 0))', '--cl-color-card-foreground': 'light-dark(oklch(0.145 0 0), oklch(0.985 0 0))',