diff --git a/packages/gamut-styles/src/themes/core.ts b/packages/gamut-styles/src/themes/core.ts index 3a6da844330..c9b52b390c7 100644 --- a/packages/gamut-styles/src/themes/core.ts +++ b/packages/gamut-styles/src/themes/core.ts @@ -3,6 +3,7 @@ import { createTheme } from '@codecademy/variance'; import { borderRadii, containerQueries, + coreElevation, corePalette, elements, fontFamily, @@ -140,6 +141,7 @@ export const coreTheme = createTheme({ 1: `1px solid ${colors['border-primary']}`, 2: `2px solid ${colors['border-primary']}`, })) + .addScale('elevation', coreElevation) .createScaleVariables('elements') .addName('core') .build(); diff --git a/packages/gamut-styles/src/themes/percipio.ts b/packages/gamut-styles/src/themes/percipio.ts index 71b6a5416ba..a6fda379929 100644 --- a/packages/gamut-styles/src/themes/percipio.ts +++ b/packages/gamut-styles/src/themes/percipio.ts @@ -2,6 +2,7 @@ import { createTheme } from '@codecademy/variance'; import { fontWeightMediumTitle, + percipioElevation, percipioFontFamily, percipioPalette, } from '../variables'; @@ -62,6 +63,7 @@ export const percipioTheme = createTheme({ }, }, }) + .addScale('elevation', percipioElevation) .addName('percipio') .build(); diff --git a/packages/gamut-styles/src/variables/elevation.ts b/packages/gamut-styles/src/variables/elevation.ts new file mode 100644 index 00000000000..a64fe989080 --- /dev/null +++ b/packages/gamut-styles/src/variables/elevation.ts @@ -0,0 +1,74 @@ +type ThemeColors = Record; + +/** + * The visual treatment of a surface at a single elevation state. + * These are type aliases rather than interfaces so they keep the implicit + * index signatures `addScale`'s constraint requires. + */ +export type ElevationStateStyles = { + shadow: string; + transform: string; +}; + +/** + * State keys are camelCase: variance's `LiteralPaths` splits token paths on + * `-`, so a hyphenated key like `hover-mirrored` would resolve to `never` and + * drop out of the theme type. `hoverMirrored` is for surfaces that cast their + * shadow to the opposite side (e.g. Card's `patternRight` shadow). + */ +export type ElevationScale = { + rest: ElevationStateStyles; + hover: ElevationStateStyles; + hoverMirrored: ElevationStateStyles; +}; + +export type ElevationState = keyof ElevationScale; + +/** + * Elevation scales describe how a surface renders shadow and lift at rest and + * on hover. Each theme provides its own scale via + * `.addScale('elevation', ...)`, which passes in that theme's `colors` — so + * `colors['shadow-primary']` resolves per theme and per color mode without + * this file ever importing a theme (which would be a circular dependency). + * + * The shared return type guarantees every theme emits the same elevation + * tokens, so lookups like `theme.elevation['hover-shadow']` are safe under + * any theme. + */ +export type ElevationScaleFactory = (theme: { + colors: ThemeColors; +}) => ElevationScale; + +export const coreElevation: ElevationScaleFactory = ({ colors }) => { + const shadowPrimary = colors['shadow-primary']; + const offset = 8; + const lift = 4; + const shadow = (x: number) => `${x}px ${offset}px 0 0 ${shadowPrimary}`; + + return { + rest: { shadow: `0 0 0 0 ${shadowPrimary}`, transform: 'none' }, + hover: { + shadow: shadow(-offset), + transform: `translate(${lift}px, -${lift}px)`, + }, + hoverMirrored: { + shadow: shadow(offset), + transform: `translate(-${lift}px, -${lift}px)`, + }, + }; +}; + +export const percipioElevation: ElevationScaleFactory = ({ colors }) => { + const shadowPrimary = colors['shadow-primary']; + const shadowSecondary = colors['shadow-secondary']; + const hover = `0 1px 4px 0 ${shadowPrimary}, 0 2px 11px 0 ${shadowSecondary}`; + + return { + rest: { + shadow: `0 1px 4px 0 ${shadowPrimary}, 0 2px 7px 0 ${shadowPrimary}`, + transform: 'none', + }, + hover: { shadow: hover, transform: 'none' }, + hoverMirrored: { shadow: hover, transform: 'none' }, + }; +}; diff --git a/packages/gamut-styles/src/variables/index.ts b/packages/gamut-styles/src/variables/index.ts index d4f074462ef..33dc5a67c22 100644 --- a/packages/gamut-styles/src/variables/index.ts +++ b/packages/gamut-styles/src/variables/index.ts @@ -1,6 +1,7 @@ export * from './borderRadii'; export * from './colors'; export * from './elements'; +export * from './elevation'; export * from './responsive'; export * from './spacing'; export * from './timing'; diff --git a/packages/gamut/src/Card/index.tsx b/packages/gamut/src/Card/index.tsx index 4098940d18c..30cb2b48016 100644 --- a/packages/gamut/src/Card/index.tsx +++ b/packages/gamut/src/Card/index.tsx @@ -3,7 +3,7 @@ import { borderRadii, Colors } from '@codecademy/gamut-styles'; import * as React from 'react'; import { DynamicCardWrapper, MotionBox, StaticCardWrapper } from './elements'; -import { hoverShadowLeft, hoverShadowRight, patternFadeInOut } from './styles'; +import { patternFadeInOut, useCardElevation } from './styles'; import { CardProps } from './types'; type BorderRadiusToken = keyof typeof borderRadii; @@ -30,10 +30,7 @@ export const Card: React.FC = ({ const hasPattern = shadow === 'patternLeft' || shadow === 'patternRight'; const isOutline = shadow === 'outline'; - const setHoverShadow = - shadow === 'patternRight' - ? hoverShadowRight(resolvedBorderRadius) - : hoverShadowLeft(resolvedBorderRadius); + const setHoverShadow = useCardElevation(shadow, resolvedBorderRadius); const initialVariant = isOutline ? 'initialOutline' : 'initial'; const animateVariant = isOutline ? 'animateOutline' : 'animate'; diff --git a/packages/gamut/src/Card/styles.tsx b/packages/gamut/src/Card/styles.tsx index 1a080095ea7..5a43de4f401 100644 --- a/packages/gamut/src/Card/styles.tsx +++ b/packages/gamut/src/Card/styles.tsx @@ -1,8 +1,39 @@ -import { theme, timingValues, variant } from '@codecademy/gamut-styles'; +import { + ElevationState, + theme, + timingValues, + variant, +} from '@codecademy/gamut-styles'; +import { StyleProps } from '@codecademy/variance'; +import { Theme, useTheme } from '@emotion/react'; + +/** + * The theme's elevation scale is flattened to dashcase tokens (`rest-shadow`, + * `hoverMirrored-transform`, ...), so per-state groups don't exist on the + * theme at runtime. This regroups one state's tokens into a spreadable style + * object, mapping the `shadow` token onto the `boxShadow` property + * framer-motion animates. + */ +const getElevationStyles = ( + elevation: Theme['elevation'], + state: ElevationState +) => ({ + boxShadow: elevation[`${state}-shadow`], + transform: elevation[`${state}-transform`], +}); const SHADOW_OFFSET = 8; const SHADOW_OFFSET_INITIAL = 6; -const TRANSFORM_OFFSET = 4; + +const REST_TRANSITION = { + duration: timingValues.fast / 1000, + ease: 'easeOut', +} as const; + +const HOVER_TRANSITION = { + duration: timingValues.fast / 1000, + ease: 'easeIn', +} as const; export const cardVariants = variant({ defaultVariant: 'default', @@ -61,59 +92,44 @@ export const patternFadeInOut = { }, }; -export const hoverShadowLeft = (borderRadius?: string) => ({ - initial: { - boxShadow: `0px 0px 0 ${theme.colors['shadow-primary']}`, - borderRadius, - transition: { - duration: timingValues.fast / 1000, - ease: 'easeOut', +/** + * Motion variants for a Card's hover elevation, read from the active theme's + * `elevation` scale so each theme controls its own shadow and lift. + * `patternRight` cards cast their shadow on the opposite side, so they use the + * `hoverMirrored` tokens. + */ +export const useCardElevation = ( + shadow: StyleProps['shadow'], + borderRadius?: string +) => { + const { elevation } = useTheme(); + const hoverState: ElevationState = + shadow === 'patternRight' ? 'hoverMirrored' : 'hover'; + + return { + initial: { + ...getElevationStyles(elevation, 'rest'), + borderRadius, + transition: REST_TRANSITION, }, - }, - initialOutline: { - boxShadow: `-${SHADOW_OFFSET_INITIAL}px ${SHADOW_OFFSET_INITIAL}px 0 0px ${theme.colors['background-current']}, -${SHADOW_OFFSET_INITIAL}px ${SHADOW_OFFSET_INITIAL}px 0 1px ${theme.colors['border-primary']}`, - borderRadius, - transition: { - duration: timingValues.fast / 1000, - ease: 'easeOut', + // outline variants keep their bespoke two-layer shadow but share the + // elevation scale's transforms + initialOutline: { + ...getElevationStyles(elevation, 'rest'), + boxShadow: `-${SHADOW_OFFSET_INITIAL}px ${SHADOW_OFFSET_INITIAL}px 0 0px ${theme.colors['background-current']}, -${SHADOW_OFFSET_INITIAL}px ${SHADOW_OFFSET_INITIAL}px 0 1px ${theme.colors['border-primary']}`, + borderRadius, + transition: REST_TRANSITION, }, - }, - animate: { - transform: `translate(${TRANSFORM_OFFSET}px, -${TRANSFORM_OFFSET}px)`, - boxShadow: `-${SHADOW_OFFSET}px ${SHADOW_OFFSET}px 0 ${theme.colors['shadow-primary']}`, - borderRadius, - transition: { - duration: timingValues.fast / 1000, - ease: 'easeIn', + animate: { + ...getElevationStyles(elevation, hoverState), + borderRadius, + transition: HOVER_TRANSITION, }, - }, - animateOutline: { - transform: `translate(${TRANSFORM_OFFSET}px, -${TRANSFORM_OFFSET}px)`, - boxShadow: `-${SHADOW_OFFSET}px ${SHADOW_OFFSET}px 0 0px ${theme.colors['shadow-primary']}, -${SHADOW_OFFSET}px ${SHADOW_OFFSET}px 0 1px ${theme.colors['shadow-primary']}`, - borderRadius, - transition: { - duration: timingValues.fast / 1000, - ease: 'easeIn', - }, - }, -}); - -export const hoverShadowRight = (borderRadius?: string) => ({ - initial: { - boxShadow: `0px 0px 0 ${theme.colors['shadow-primary']}`, - borderRadius, - transition: { - duration: timingValues.fast / 1000, - ease: 'easeOut', + animateOutline: { + ...getElevationStyles(elevation, 'hover'), + boxShadow: `-${SHADOW_OFFSET}px ${SHADOW_OFFSET}px 0 0px ${theme.colors['shadow-primary']}, -${SHADOW_OFFSET}px ${SHADOW_OFFSET}px 0 1px ${theme.colors['shadow-primary']}`, + borderRadius, + transition: HOVER_TRANSITION, }, - }, - animate: { - transform: `translate(-${TRANSFORM_OFFSET}px, -${TRANSFORM_OFFSET}px)`, - boxShadow: `${SHADOW_OFFSET}px ${SHADOW_OFFSET}px 0 ${theme.colors['shadow-primary']}`, - borderRadius, - transition: { - duration: timingValues.fast / 1000, - ease: 'easeIn', - }, - }, -}); + }; +}; diff --git a/packages/styleguide/src/lib/Foundations/Theme/CoreTheme.mdx b/packages/styleguide/src/lib/Foundations/Theme/CoreTheme.mdx index b52036e1322..d9c951ed44a 100644 --- a/packages/styleguide/src/lib/Foundations/Theme/CoreTheme.mdx +++ b/packages/styleguide/src/lib/Foundations/Theme/CoreTheme.mdx @@ -111,3 +111,13 @@ export const lorem = `Lorem ipsum dolor sit amet, consectetur adipiscing elit, s **Key**: `borderRadii` + +## Elevation + +**Key**: `elevation` + +Elevation describes how a surface (such as `Card`) renders shadow and lift. The scale has three states — `rest`, `hover`, and `hoverMirrored` — each providing a `shadow` and a `transform` token, accessible with a dashcase key `elevation['${state}-${property}']`. The `hoverMirrored` state is used when a surface casts its shadow to the right instead of the left (e.g. `Card`'s `patternRight` shadow). + +Shadow colors reference the `shadow-primary` color mode alias, so they respond to light and dark mode automatically, and themes can provide their own scale via `addScale('elevation', ...)` — Percipio, for example, replaces the hard offset shadow with a soft blurred one and no lift. + + diff --git a/packages/styleguide/src/lib/Foundations/Theme/PercipioTheme.mdx b/packages/styleguide/src/lib/Foundations/Theme/PercipioTheme.mdx index ccccfd3e977..10f19928dbd 100644 --- a/packages/styleguide/src/lib/Foundations/Theme/PercipioTheme.mdx +++ b/packages/styleguide/src/lib/Foundations/Theme/PercipioTheme.mdx @@ -96,3 +96,11 @@ Percipio currently only supports `light` mode. The Percipio theme uses Skillsoft Sans for accent text and Skillsoft Text for body text. Roboto Mono is used for monospace, and Roboto sans-serif for the `system` font family slot. + +## Elevation + +**Key**: `elevation` + +The Percipio theme provides its own elevation scale: surfaces cast a soft, blurred shadow rather than the Core theme's hard offset shadow, and they do not lift on hover — every `transform` token is `none`. The scale has the same three states as the Core theme — `rest`, `hover`, and `hoverMirrored` — each providing a `shadow` and a `transform` token accessible with a dashcase key `elevation['${state}-${property}']`, so components like `Card` work across themes without changes. + + diff --git a/packages/styleguide/src/lib/Foundations/shared/elements.tsx b/packages/styleguide/src/lib/Foundations/shared/elements.tsx index 1ec160156af..4558c545279 100644 --- a/packages/styleguide/src/lib/Foundations/shared/elements.tsx +++ b/packages/styleguide/src/lib/Foundations/shared/elements.tsx @@ -3,13 +3,14 @@ import { Background, coreSwatches, css, + ElevationState, lxStudioColors, theme, trueColors, } from '@codecademy/gamut-styles'; // eslint-disable-next-line gamut/import-paths import * as ALL_PROPS from '@codecademy/gamut-styles/src/variance/config'; -import { useTheme } from '@emotion/react'; +import { Theme, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import kebabCase from 'lodash/kebabCase'; import { useMemo } from 'react'; @@ -541,3 +542,80 @@ export const getPropRows = (key: keyof typeof ALL_PROPS) => id: prop, ...config, })); + +const ElevationExample = styled(Box)<{ + exampleShadow?: string; + exampleTransform?: string; +}>( + css({ + bg: 'background-current', + border: 1, + display: 'inline-block', + height: '3rem', + width: '5rem', + }), + ({ exampleShadow, exampleTransform }) => ({ + boxShadow: exampleShadow, + transform: exampleTransform, + }) +); + +const elevationStates: ElevationState[] = ['rest', 'hover', 'hoverMirrored']; + +/** + * Every theme emits the same elevation tokens (see `ElevationScale` in + * gamut-styles), so each theme's docs page can build its table from its own + * scale — see `percipioElements` for the Percipio table. + */ +export const createElevationTable = (elevationScale: Theme['elevation']) => ({ + rows: elevationStates.map((id) => ({ + id, + shadow: elevationScale[`${id}-shadow`], + transform: elevationScale[`${id}-transform`], + })), + columns: [ + PROP_COLUMN, + { + ...PATH_COLUMN, + render: ({ id }: any) => ( + + {`theme.elevation['${id}-shadow']`} + {`theme.elevation['${id}-transform']`} + + ), + }, + { + key: 'value', + name: 'Value', + size: 'lg', + render: ({ shadow, transform }: any) => ( + + {shadow} + {transform} + + ), + }, + { + key: 'example', + name: 'Example', + size: 'fill', + // dashed outline marks the resting position the transform lifts from + render: ({ shadow, transform }: any) => ( + + + + ), + }, + ], +}); + +export const elevation = createElevationTable(theme.elevation); diff --git a/packages/styleguide/src/lib/Foundations/shared/percipioElements.tsx b/packages/styleguide/src/lib/Foundations/shared/percipioElements.tsx index d33c0c7bd31..570948edb02 100644 --- a/packages/styleguide/src/lib/Foundations/shared/percipioElements.tsx +++ b/packages/styleguide/src/lib/Foundations/shared/percipioElements.tsx @@ -9,12 +9,15 @@ import { Code, ColorScale, TokenTable } from '~styleguide/blocks'; import { applyCorrectNotation } from './applyCorrectNotation'; import { + createElevationTable, createExampleColumn, PATH_COLUMN, PROP_COLUMN, VALUE_COLUMN, } from './elements'; +export const percipioElevation = createElevationTable(percipioTheme.elevation); + export const percipioColor = { rows: Object.entries(percipioColors).map(([id, value]) => ({ id,