From f5dd03ad6c13c9d0872d27ea034dc26006a5c7ef Mon Sep 17 00:00:00 2001 From: Anna Nguyen Date: Fri, 5 Jun 2026 10:05:04 -0400 Subject: [PATCH] Add dev-only spacing scale lab for A/B testing. Compare grid4, Fibonacci, and modular unified scales on localhost:3000 via a floating switcher without affecting production builds. Co-authored-by: Cursor --- .../contexts/ScalePresetContext.tsx | 155 ++++++++++++++ .../src/components/icons/createIcon.tsx | 10 +- .../src/hooks/useScaledThemes.ts | 36 ++++ assets/design-system/src/index.ts | 13 ++ assets/design-system/src/theme.tsx | 120 +++++++---- .../src/theme/HonorableThemeProvider.tsx | 11 +- assets/design-system/src/theme/borders.ts | 17 +- assets/design-system/src/theme/iconSizes.ts | 16 ++ .../design-system/src/theme/scale-presets.ts | 191 ++++++++++++++++++ assets/design-system/src/theme/spacing.ts | 55 ++--- assets/design-system/src/theme/text-scales.ts | 108 ++++++++++ assets/src/App.tsx | 23 ++- 12 files changed, 664 insertions(+), 91 deletions(-) create mode 100644 assets/design-system/src/components/contexts/ScalePresetContext.tsx create mode 100644 assets/design-system/src/hooks/useScaledThemes.ts create mode 100644 assets/design-system/src/theme/iconSizes.ts create mode 100644 assets/design-system/src/theme/scale-presets.ts create mode 100644 assets/design-system/src/theme/text-scales.ts diff --git a/assets/design-system/src/components/contexts/ScalePresetContext.tsx b/assets/design-system/src/components/contexts/ScalePresetContext.tsx new file mode 100644 index 0000000000..389bb0ad10 --- /dev/null +++ b/assets/design-system/src/components/contexts/ScalePresetContext.tsx @@ -0,0 +1,155 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from 'react' +import styled from 'styled-components' + +import { + DEFAULT_SCALE_PRESET_ID, + SCALE_PRESET_IDS, + isScalePresetId, + scalePresets, + type ScalePresetId, +} from '../../theme/scale-presets' + +const STORAGE_KEY = 'plural-prototype-scale' + +type ScalePresetContextValue = { + scaleId: ScalePresetId + setScaleId: (id: ScalePresetId) => void + switcherEnabled: boolean +} + +const ScalePresetContext = createContext({ + scaleId: DEFAULT_SCALE_PRESET_ID, + setScaleId: () => {}, + switcherEnabled: false, +}) + +function readStoredScaleId(): ScalePresetId { + if (typeof window === 'undefined') return DEFAULT_SCALE_PRESET_ID + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (isScalePresetId(stored)) return stored + } catch { + /* ignore */ + } + return DEFAULT_SCALE_PRESET_ID +} + +function persistScaleId(id: ScalePresetId) { + try { + localStorage.setItem(STORAGE_KEY, id) + } catch { + /* ignore */ + } +} + +export function ScalePresetProvider({ + children, + switcherEnabled = false, +}: { + children: ReactNode + switcherEnabled?: boolean +}) { + const [scaleId, setScaleIdState] = useState(readStoredScaleId) + + const setScaleId = useCallback((id: ScalePresetId) => { + setScaleIdState(id) + persistScaleId(id) + }, []) + + const effectiveScaleId = switcherEnabled ? scaleId : DEFAULT_SCALE_PRESET_ID + + const value = useMemo( + () => ({ + scaleId: effectiveScaleId, + setScaleId, + switcherEnabled, + }), + [effectiveScaleId, setScaleId, switcherEnabled] + ) + + return ( + + {children} + + ) +} + +export function useScalePreset() { + return useContext(ScalePresetContext) +} + +const Panel = styled.div(({ theme }) => ({ + position: 'fixed', + bottom: theme.spacing.medium, + right: theme.spacing.medium, + zIndex: 3000, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing.xxsmall, + padding: theme.spacing.small, + background: theme.colors['fill-two'], + border: theme.borders.default, + borderRadius: theme.borderRadiuses.large, + boxShadow: theme.boxShadows.moderate, + fontSize: theme.partials.text.caption.fontSize, + color: theme.colors.text, + minWidth: 140, +})) + +const Title = styled.div(({ theme }) => ({ + fontWeight: 600, + letterSpacing: '0.5px', + color: theme.colors['text-xlight'], + marginBottom: theme.spacing.xxxsmall, +})) + +const ScaleButton = styled.button<{ $active?: boolean }>( + ({ theme, $active }) => ({ + textAlign: 'left', + padding: `${theme.spacing.xxsmall}px ${theme.spacing.xsmall}px`, + borderRadius: theme.borderRadiuses.medium, + background: $active ? theme.colors['fill-three'] : 'transparent', + color: $active ? theme.colors.text : theme.colors['text-light'], + border: `1px solid ${$active ? theme.colors['border-fill-three'] : 'transparent'}`, + cursor: 'pointer', + font: 'inherit', + transition: 'background 0.15s ease, color 0.15s ease', + '&:hover': { + background: theme.colors['fill-three-hover'], + color: theme.colors.text, + }, + }) +) + +export function DevScaleLabToggle() { + const { scaleId, setScaleId, switcherEnabled } = useScalePreset() + + if (!switcherEnabled) return null + + return ( + + Scale + {SCALE_PRESET_IDS.map((id) => ( + setScaleId(id)} + > + {scalePresets[id].label} + + ))} + + ) +} diff --git a/assets/design-system/src/components/icons/createIcon.tsx b/assets/design-system/src/components/icons/createIcon.tsx index f65f78d23e..9e597c115d 100644 --- a/assets/design-system/src/components/icons/createIcon.tsx +++ b/assets/design-system/src/components/icons/createIcon.tsx @@ -4,6 +4,9 @@ import { useTheme, } from 'honorable' import { type ReactNode } from 'react' +import { useTheme as useStyledTheme } from 'styled-components' + +import { DEFAULT_ICON_SIZE } from '../../theme/iconSizes' type IconBaseProps = { size?: number | string @@ -18,14 +21,17 @@ export type IconProps = HonorableIconProps & IconBaseProps function createIcon(render: (props: IconBaseProps) => ReactNode) { function Icon({ ref, - size = 16, + size, color = 'currentColor', fullColor, secondaryColor, ...props }: IconProps) { const theme = useTheme() + const styledTheme = useStyledTheme() const workingColor = theme.utils?.resolveColorString(color) + const resolvedSize = + size ?? styledTheme.iconSizes?.medium ?? DEFAULT_ICON_SIZE return ( ReactNode) { {...props} > {render({ - size, + size: resolvedSize, color: workingColor, secondaryColor, fullColor, diff --git a/assets/design-system/src/hooks/useScaledThemes.ts b/assets/design-system/src/hooks/useScaledThemes.ts new file mode 100644 index 0000000000..147af6a304 --- /dev/null +++ b/assets/design-system/src/hooks/useScaledThemes.ts @@ -0,0 +1,36 @@ +import { useMemo } from 'react' + +import { + getHonorableTheme, + getStyledTheme, + styledThemeLight, + useThemeColorMode, + type ColorMode, + type ScalePresetId, +} from '../theme' + +export function useScaledThemes(scaleId: ScalePresetId) { + const colorMode = useThemeColorMode() + + const styledTheme = useMemo(() => { + const mode = (colorMode === 'light' ? 'light' : 'dark') as ColorMode + if (mode === 'light') { + return { + ...getStyledTheme({ mode: 'light', scaleId }), + colors: styledThemeLight.colors, + } + } + return getStyledTheme({ mode: 'dark', scaleId }) + }, [colorMode, scaleId]) + + const honorableTheme = useMemo( + () => + getHonorableTheme({ + mode: (colorMode === 'light' ? 'light' : 'dark') as ColorMode, + scaleId, + }), + [colorMode, scaleId] + ) + + return { styledTheme, honorableTheme } +} diff --git a/assets/design-system/src/index.ts b/assets/design-system/src/index.ts index 3da3d5e414..a6501f9e38 100644 --- a/assets/design-system/src/index.ts +++ b/assets/design-system/src/index.ts @@ -179,8 +179,14 @@ export * from './components/TreeNavigation' // Theme export { default as GlobalStyle } from './GlobalStyle' export { + DEFAULT_SCALE_PRESET_ID, + SCALE_PRESET_IDS, + getHonorableTheme, + getStyledTheme, honorableThemeDark, honorableThemeLight, + isScalePresetId, + scalePresets, setThemeColorMode, styledTheme, styledThemeDark, @@ -188,6 +194,13 @@ export { honorableThemeDark as theme, useThemeColorMode, } from './theme' +export type { ScalePresetId } from './theme' +export { + DevScaleLabToggle, + ScalePresetProvider, + useScalePreset, +} from './components/contexts/ScalePresetContext' +export { useScaledThemes } from './hooks/useScaledThemes' export type { SemanticBorderKey } from './theme/borders' export { semanticColorCssVars, semanticColorKeys } from './theme/colors' export type { SemanticColorCssVar, SemanticColorKey } from './theme/colors' diff --git a/assets/design-system/src/theme.tsx b/assets/design-system/src/theme.tsx index 79911440a7..d128c4a287 100644 --- a/assets/design-system/src/theme.tsx +++ b/assets/design-system/src/theme.tsx @@ -6,12 +6,8 @@ import { useState } from 'react' import { useMutationObserver } from '@react-hooks-library/core' -import { - borderRadiuses, - borderStyles, - borderWidths, - borders, -} from './theme/borders' +import { getBorderRadiusesForScale } from './theme/borders' +import { borderStyles, borderWidths, borders } from './theme/borders' import { getBoxShadows } from './theme/boxShadows' import { baseColors } from './theme/colors-base' import { semanticColorsDark } from './theme/colors-semantic-dark' @@ -22,8 +18,13 @@ import gradients from './theme/gradients' import { marketingTextPartials } from './theme/marketingText' import { resetPartials } from './theme/resets' import { scrollBar } from './theme/scrollBar' -import { spacing } from './theme/spacing' -import { textPartials } from './theme/text' +import { getIconSizesForScale } from './theme/iconSizes' +import { + DEFAULT_SCALE_PRESET_ID, + type ScalePresetId, +} from './theme/scale-presets' +import { getSpacingForScale } from './theme/spacing' +import { getTextPartialsForScale } from './theme/text-scales' import { visuallyHidden } from './theme/visuallyHidden' import { zIndexes } from './theme/zIndexes' @@ -83,14 +84,23 @@ const getBaseTheme = ({ mode }: { mode: ColorMode }) => // remove any unused themes as we transition off honorable // ultimately we'll be able to get rid of this entirely -const getHonorableThemeProps = ({ mode }: { mode: ColorMode }) => { +const getHonorableThemeProps = ({ + mode, + scaleId = DEFAULT_SCALE_PRESET_ID, +}: { + mode: ColorMode + scaleId?: ScalePresetId +}) => { const boxShadows = getBoxShadows({ mode }) + const scaleSpacing = getSpacingForScale(scaleId) + const scaleBorderRadiuses = getBorderRadiusesForScale(scaleId) + const scaleTextPartials = getTextPartialsForScale(scaleId) return { stylesheet: { html: [ { - fontSize: 14, + fontSize: scaleTextPartials.body2.fontSize, fontFamily: fontFamilies.sans, backgroundColor: 'fill-zero', }, @@ -99,7 +109,7 @@ const getHonorableThemeProps = ({ mode }: { mode: ColorMode }) => { }, global: [ /* Spacing */ - mapperRecipe('gap', spacing), + mapperRecipe('gap', scaleSpacing), ...Object.entries(spacers).map( ([key, nextKeys]) => (props: any) => @@ -108,40 +118,40 @@ const getHonorableThemeProps = ({ mode }: { mode: ColorMode }) => { Object.fromEntries( nextKeys.map((nextKey) => [ nextKey, - (spacing as any)[props[key]] || props[key], + (scaleSpacing as any)[props[key]] || props[key], ]) ) ), /* Border radiuses */ - mapperRecipe('borderRadius', borderRadiuses), + mapperRecipe('borderRadius', scaleBorderRadiuses), /* Shadows */ mapperRecipe('boxShadow', boxShadows), /* Texts */ - ({ h1 }: any) => h1 && textPartials.h1, - ({ h2 }: any) => h2 && textPartials.h2, - ({ h3 }: any) => h3 && textPartials.h3, - ({ h4 }: any) => h4 && textPartials.h4, - ({ title1 }: any) => title1 && textPartials.title1, - ({ title2 }: any) => title2 && textPartials.title2, - ({ subtitle1 }: any) => subtitle1 && textPartials.subtitle1, - ({ subtitle2 }: any) => subtitle2 && textPartials.subtitle2, + ({ h1 }: any) => h1 && scaleTextPartials.h1, + ({ h2 }: any) => h2 && scaleTextPartials.h2, + ({ h3 }: any) => h3 && scaleTextPartials.h3, + ({ h4 }: any) => h4 && scaleTextPartials.h4, + ({ title1 }: any) => title1 && scaleTextPartials.title1, + ({ title2 }: any) => title2 && scaleTextPartials.title2, + ({ subtitle1 }: any) => subtitle1 && scaleTextPartials.subtitle1, + ({ subtitle2 }: any) => subtitle2 && scaleTextPartials.subtitle2, ({ body1, body2, bold }: any) => ({ - ...(body1 && textPartials.body1), - ...(body2 && textPartials.body2), - ...((body1 || body2) && bold && textPartials.bodyBold), + ...(body1 && scaleTextPartials.body1), + ...(body2 && scaleTextPartials.body2), + ...((body1 || body2) && bold && scaleTextPartials.bodyBold), }), ({ body2LooseLineHeight, bold }: any) => ({ - ...(body2LooseLineHeight && textPartials.body2LooseLineHeight), - ...(body2LooseLineHeight && bold && textPartials.bodyBold), + ...(body2LooseLineHeight && scaleTextPartials.body2LooseLineHeight), + ...(body2LooseLineHeight && bold && scaleTextPartials.bodyBold), }), - ({ caption }: any) => caption && textPartials.caption, - ({ overline }: any) => overline && textPartials.overline, - ({ truncate }: any) => truncate && textPartials.truncate, + ({ caption }: any) => caption && scaleTextPartials.caption, + ({ overline }: any) => overline && scaleTextPartials.overline, + ({ truncate }: any) => truncate && scaleTextPartials.truncate, ], A: { Root: [ { color: 'text' }, - ({ inline }: any) => inline && textPartials.inlineLink, + ({ inline }: any) => inline && scaleTextPartials.inlineLink, ], }, Avatar: { @@ -305,25 +315,39 @@ const getHonorableThemeProps = ({ mode }: { mode: ColorMode }) => { } } -export const honorableThemeDark = mergeTheme(defaultTheme, { - ...getBaseTheme({ mode: 'dark' }), - colors: colorsDark, - ...getHonorableThemeProps({ mode: 'dark' }), -}) +export function getHonorableTheme({ + mode, + scaleId = DEFAULT_SCALE_PRESET_ID, +}: { + mode: ColorMode + scaleId?: ScalePresetId +}) { + return mergeTheme(defaultTheme, { + ...getBaseTheme({ mode }), + colors: mode === 'dark' ? colorsDark : colorsLight, + ...getHonorableThemeProps({ mode, scaleId }), + }) +} + +export const honorableThemeDark = getHonorableTheme({ mode: 'dark' }) -export const honorableThemeLight = mergeTheme(defaultTheme, { - ...getBaseTheme({ mode: 'light' }), - colors: colorsLight, - ...getHonorableThemeProps({ mode: 'light' }), -}) +export const honorableThemeLight = getHonorableTheme({ mode: 'light' }) -const getStyledTheme = ({ mode }: { mode: ColorMode }) => +export const getStyledTheme = ({ + mode, + scaleId = DEFAULT_SCALE_PRESET_ID, +}: { + mode: ColorMode + scaleId?: ScalePresetId +}) => ({ ...getBaseTheme({ mode }), ...{ - spacing, + scaleId, + spacing: getSpacingForScale(scaleId), + iconSizes: getIconSizesForScale(scaleId), boxShadows: getBoxShadows({ mode }), - borderRadiuses, + borderRadiuses: getBorderRadiusesForScale(scaleId), fontFamilies, borders, borderStyles, @@ -332,7 +356,7 @@ const getStyledTheme = ({ mode }: { mode: ColorMode }) => portals, gradients, partials: { - text: textPartials, + text: getTextPartialsForScale(scaleId), marketingText: marketingTextPartials, focus: getFocusPartials(), scrollBar, @@ -356,6 +380,14 @@ export const styledThemeLight = { colors: colorsLight, } as const +export { + DEFAULT_SCALE_PRESET_ID, + SCALE_PRESET_IDS, + isScalePresetId, + scalePresets, +} from './theme/scale-presets' +export type { ScalePresetId } from './theme/scale-presets' + // Deprecate these later? export const styledTheme = styledThemeDark export default honorableThemeDark diff --git a/assets/design-system/src/theme/HonorableThemeProvider.tsx b/assets/design-system/src/theme/HonorableThemeProvider.tsx index d7d14163e0..28e95833f0 100644 --- a/assets/design-system/src/theme/HonorableThemeProvider.tsx +++ b/assets/design-system/src/theme/HonorableThemeProvider.tsx @@ -1,9 +1,10 @@ import { FC, ReactNode } from 'react' import { CssBaseline, ThemeProvider, ThemeProviderProps } from 'honorable' -import { honorableThemeDark, honorableThemeLight, useThemeColorMode } from '..' -// workarounds for broken type from honorable +import { useScalePreset } from '../components/contexts/ScalePresetContext' +import { useScaledThemes } from '../hooks/useScaledThemes' + const TypedHonorableThemeProvider = ThemeProvider as FC const TypedCssBaseline = CssBaseline as any @@ -12,10 +13,8 @@ export default function HonorableThemeProvider({ }: { children: ReactNode }) { - const colorMode = useThemeColorMode() - - const honorableTheme = - colorMode === 'light' ? honorableThemeLight : honorableThemeDark + const { scaleId } = useScalePreset() + const { honorableTheme } = useScaledThemes(scaleId) return ( diff --git a/assets/design-system/src/theme/borders.ts b/assets/design-system/src/theme/borders.ts index 98fd16902d..506b4da948 100644 --- a/assets/design-system/src/theme/borders.ts +++ b/assets/design-system/src/theme/borders.ts @@ -1,5 +1,10 @@ import { type CSSProperties } from 'react' +import { + DEFAULT_SCALE_PRESET_ID, + getScalePreset, + type ScalePresetId, +} from './scale-presets' import { semanticColorCssVars } from './colors' export type SemanticBorderKey = keyof typeof borders @@ -23,7 +28,11 @@ export const borders = { selected: `${borderWidths.default}px ${borderStyles.default} ${semanticColorCssVars['border-selected']}`, } as const satisfies Record -export const borderRadiuses = { - medium: 3, - large: 6, -} as const satisfies Record +export function getBorderRadiusesForScale( + scaleId: ScalePresetId = DEFAULT_SCALE_PRESET_ID +) { + const { medium, large } = getScalePreset(scaleId).borderRadiuses + return { medium, large } as const +} + +export const borderRadiuses = getBorderRadiusesForScale(DEFAULT_SCALE_PRESET_ID) diff --git a/assets/design-system/src/theme/iconSizes.ts b/assets/design-system/src/theme/iconSizes.ts new file mode 100644 index 0000000000..71312da440 --- /dev/null +++ b/assets/design-system/src/theme/iconSizes.ts @@ -0,0 +1,16 @@ +import { + DEFAULT_SCALE_PRESET_ID, + getScalePreset, + type IconSizeRecord, + type ScalePresetId, +} from './scale-presets' + +export const iconSizes = getScalePreset(DEFAULT_SCALE_PRESET_ID).iconSizes + +export function getIconSizesForScale( + scaleId: ScalePresetId = DEFAULT_SCALE_PRESET_ID +): IconSizeRecord { + return getScalePreset(scaleId).iconSizes +} + +export const DEFAULT_ICON_SIZE = iconSizes.medium diff --git a/assets/design-system/src/theme/scale-presets.ts b/assets/design-system/src/theme/scale-presets.ts new file mode 100644 index 0000000000..62bdab2804 --- /dev/null +++ b/assets/design-system/src/theme/scale-presets.ts @@ -0,0 +1,191 @@ +/** + * Alternate unified scales for spacing scale lab. + * Default production theme uses grid4. Dev switcher: ScalePresetProvider. + */ + +export const SCALE_PRESET_IDS = ['grid4', 'fibonacci', 'modular'] as const +export type ScalePresetId = (typeof SCALE_PRESET_IDS)[number] +export const DEFAULT_SCALE_PRESET_ID: ScalePresetId = 'grid4' + +export type BaseSpacingRecord = { + xxxsmall: number + xxsmall: number + xsmall: number + small: number + medium: number + large: number + xlarge: number + xxlarge: number + xxxlarge: number + xxxxlarge: number + xxxxxlarge: number + xxxxxxlarge: number +} + +export type BorderRadiusRecord = { + small: number + medium: number + large: number +} + +export type IconSizeRecord = { + xsmall: number + small: number + medium: number + large: number + xlarge: number +} + +export type TypographyScaleRecord = { + caption: number + body2: number + body1: number + subtitle2: number + subtitle1: number + title2: number + title1: number + lhCaption: number + lhBody2: number + lhBody1: number + lhSubtitle2: number + lhSubtitle1: number + lhTitle2: number + lhTitle1: number +} + +export type ScalePreset = { + id: ScalePresetId + label: string + baseSpacing: BaseSpacingRecord + borderRadiuses: BorderRadiusRecord + iconSizes: IconSizeRecord + typography: TypographyScaleRecord +} + +const grid4: ScalePreset = { + id: 'grid4', + label: '4px grid', + baseSpacing: { + xxxsmall: 2, + xxsmall: 4, + xsmall: 8, + small: 12, + medium: 16, + large: 24, + xlarge: 32, + xxlarge: 48, + xxxlarge: 64, + xxxxlarge: 96, + xxxxxlarge: 128, + xxxxxxlarge: 192, + }, + borderRadiuses: { small: 2, medium: 3, large: 6 }, + iconSizes: { xsmall: 12, small: 14, medium: 16, large: 20, xlarge: 24 }, + typography: { + caption: 12, + body2: 14, + body1: 16, + subtitle2: 18, + subtitle1: 20, + title2: 24, + title1: 30, + lhCaption: 16, + lhBody2: 20, + lhBody1: 24, + lhSubtitle2: 24, + lhSubtitle1: 24, + lhTitle2: 32, + lhTitle1: 40, + }, +} + +const fibonacci: ScalePreset = { + id: 'fibonacci', + label: 'Fibonacci', + baseSpacing: { + xxxsmall: 2, + xxsmall: 3, + xsmall: 5, + small: 8, + medium: 13, + large: 21, + xlarge: 34, + xxlarge: 55, + xxxlarge: 89, + xxxxlarge: 144, + xxxxxlarge: 192, + xxxxxxlarge: 256, + }, + borderRadiuses: { small: 2, medium: 5, large: 8 }, + iconSizes: { xsmall: 11, small: 13, medium: 16, large: 21, xlarge: 26 }, + typography: { + caption: 11, + body2: 13, + body1: 16, + subtitle2: 18, + subtitle1: 21, + title2: 26, + title1: 34, + lhCaption: 16, + lhBody2: 21, + lhBody1: 24, + lhSubtitle2: 26, + lhSubtitle1: 28, + lhTitle2: 34, + lhTitle1: 42, + }, +} + +const modular: ScalePreset = { + id: 'modular', + label: 'Modular (~1.5×)', + baseSpacing: { + xxxsmall: 4, + xxsmall: 6, + xsmall: 9, + small: 14, + medium: 21, + large: 32, + xlarge: 48, + xxlarge: 72, + xxxlarge: 108, + xxxxlarge: 162, + xxxxxlarge: 243, + xxxxxxlarge: 288, + }, + borderRadiuses: { small: 3, medium: 5, large: 9 }, + iconSizes: { xsmall: 12, small: 14, medium: 17, large: 21, xlarge: 28 }, + typography: { + caption: 12, + body2: 14, + body1: 17, + subtitle2: 20, + subtitle1: 24, + title2: 28, + title1: 36, + lhCaption: 18, + lhBody2: 21, + lhBody1: 26, + lhSubtitle2: 28, + lhSubtitle1: 32, + lhTitle2: 36, + lhTitle1: 44, + }, +} + +export const scalePresets: Record = { + grid4, + fibonacci, + modular, +} + +export function isScalePresetId(value: unknown): value is ScalePresetId { + return ( + typeof value === 'string' && + SCALE_PRESET_IDS.includes(value as ScalePresetId) + ) +} + +export function getScalePreset(id: ScalePresetId = DEFAULT_SCALE_PRESET_ID) { + return scalePresets[id] +} diff --git a/assets/design-system/src/theme/spacing.ts b/assets/design-system/src/theme/spacing.ts index f8edb40efe..e411306fbe 100644 --- a/assets/design-system/src/theme/spacing.ts +++ b/assets/design-system/src/theme/spacing.ts @@ -1,36 +1,38 @@ import { DefaultTheme, StyledObject } from 'styled-components' import { type PrefixKeys } from '../utils/ts-utils' -export type SemanticSpacingKey = keyof typeof spacing +import { + DEFAULT_SCALE_PRESET_ID, + getScalePreset, + type BaseSpacingRecord, + type ScalePresetId, +} from './scale-presets' -export const baseSpacing = { - xxxsmall: 2, // 1/8 * 16 - xxsmall: 4, // 1/4 * 16 - xsmall: 8, // 1/2 * 16 - small: 12, // 3/4 * 16 - medium: 16, // 1 * 16 - large: 24, // 1.5 * 16 - xlarge: 32, // 2 * 16 - xxlarge: 48, // 3 * 16 - xxxlarge: 64, // 4 * 16 - xxxxlarge: 96, // 6 * 16 - xxxxxlarge: 128, // 8 * 16 - xxxxxxlarge: 192, // 12 * 16 -} as const satisfies Record +export type SemanticSpacingKey = keyof typeof spacing const negativePrefix = 'minus-' as const -const negativeSpacing = Object.fromEntries( - Object.entries(baseSpacing).map((key, val) => [ - `${negativePrefix}${key}`, - -val, - ]) -) as PrefixKeys -export const spacing = { - none: 0, - ...baseSpacing, - ...negativeSpacing, -} as const satisfies Record +export function buildSpacingFromBase(base: BaseSpacingRecord) { + const negativeSpacing = Object.fromEntries( + Object.entries(base).map(([key, val]) => [`${negativePrefix}${key}`, -val]) + ) as PrefixKeys + + return { + none: 0, + ...base, + ...negativeSpacing, + } as const satisfies Record +} + +export const baseSpacing = getScalePreset(DEFAULT_SCALE_PRESET_ID).baseSpacing + +export const spacing = buildSpacingFromBase(baseSpacing) + +export function getSpacingForScale( + scaleId: ScalePresetId = DEFAULT_SCALE_PRESET_ID +) { + return buildSpacingFromBase(getScalePreset(scaleId).baseSpacing) +} const SIZING_KEYS = [ 'margin', @@ -51,7 +53,6 @@ export type SpacerProps = { [key in SpacerKey]?: T } -// separates out semantic spacing props, resolves them, and adds them to the css style object export function resolveSpacersAndSanitizeCss( props: Record & { css?: StyledObject }, { spacing }: DefaultTheme diff --git a/assets/design-system/src/theme/text-scales.ts b/assets/design-system/src/theme/text-scales.ts new file mode 100644 index 0000000000..45d61b7dd9 --- /dev/null +++ b/assets/design-system/src/theme/text-scales.ts @@ -0,0 +1,108 @@ +import { + DEFAULT_SCALE_PRESET_ID, + getScalePreset, + type ScalePresetId, + type TypographyScaleRecord, +} from './scale-presets' +import { textPartials as grid4TextPartials } from './text' + +function px(n: number) { + return `${n}px` +} + +function applyTypographyScale( + partials: typeof grid4TextPartials, + t: TypographyScaleRecord +): typeof grid4TextPartials { + return { + ...partials, + title1: { + ...partials.title1, + fontSize: t.title1, + lineHeight: px(t.lhTitle1), + }, + title2: { + ...partials.title2, + fontSize: t.title2, + lineHeight: px(t.lhTitle2), + }, + subtitle1: { + ...partials.subtitle1, + fontSize: t.subtitle1, + lineHeight: px(t.lhSubtitle1), + }, + subtitle2: { + ...partials.subtitle2, + fontSize: t.subtitle2, + lineHeight: px(t.lhSubtitle2), + }, + body1: { + ...partials.body1, + fontSize: t.body1, + lineHeight: px(t.lhBody1), + }, + body2: { + ...partials.body2, + fontSize: t.body2, + lineHeight: px(t.lhBody2), + }, + body1Bold: { + ...partials.body1Bold, + fontSize: t.body1, + lineHeight: px(t.lhBody1), + }, + body2Bold: { + ...partials.body2Bold, + fontSize: t.body2, + lineHeight: px(t.lhBody2), + }, + body2LooseLineHeight: { + ...partials.body2LooseLineHeight, + fontSize: t.body2, + }, + body2LooseLineHeightBold: { + ...partials.body2LooseLineHeightBold, + fontSize: t.body2, + }, + caption: { + ...partials.caption, + fontSize: t.caption, + lineHeight: px(t.lhCaption), + }, + badgeLabel: { + ...partials.badgeLabel, + fontSize: t.caption, + }, + buttonMedium: { + ...partials.buttonMedium, + fontSize: t.body2, + }, + buttonSmall: { + ...partials.buttonSmall, + fontSize: t.caption, + }, + buttonLarge: { + ...partials.buttonLarge, + fontSize: t.body1, + }, + overline: { + ...partials.overline, + fontSize: t.caption, + lineHeight: px(t.lhCaption), + }, + code: { + ...partials.code, + fontSize: t.body2, + }, + } as typeof grid4TextPartials +} + +export function getTextPartialsForScale( + scaleId: ScalePresetId = DEFAULT_SCALE_PRESET_ID +) { + if (scaleId === DEFAULT_SCALE_PRESET_ID) return grid4TextPartials + return applyTypographyScale( + grid4TextPartials, + getScalePreset(scaleId).typography + ) +} diff --git a/assets/src/App.tsx b/assets/src/App.tsx index 93917e2daf..61b01640e9 100644 --- a/assets/src/App.tsx +++ b/assets/src/App.tsx @@ -2,10 +2,12 @@ import { ApolloProvider } from '@apollo/client' import { loadDevMessages, loadErrorMessages } from '@apollo/client/dev' import { + DevScaleLabToggle, GlobalStyle, HonorableThemeProvider, - styledThemeDark, - styledThemeLight, + ScalePresetProvider, + useScalePreset, + useScaledThemes, useThemeColorMode, } from '@pluralsh/design-system' import * as Sentry from '@sentry/react' @@ -36,22 +38,26 @@ const sentryCreateBrowserRouter = const router = sentryCreateBrowserRouter(rootRoutes) const queryClient = new QueryClient() +const isDev = import.meta.env.DEV + export default function App() { return ( - - - + + + + + ) } function ThemeProviders({ children }: { children: ReactNode }) { - const colorMode = useThemeColorMode() - - const styledTheme = colorMode === 'light' ? styledThemeLight : styledThemeDark + useThemeColorMode() + const { scaleId } = useScalePreset() + const { styledTheme } = useScaledThemes(scaleId) return ( @@ -60,6 +66,7 @@ function ThemeProviders({ children }: { children: ReactNode }) { {children} + {isDev && }