From ebe8ee45ad5f5eeb62d476f03b348478527c4f7b Mon Sep 17 00:00:00 2001 From: "manuel.carrera" Date: Tue, 14 Jul 2026 14:50:05 -0600 Subject: [PATCH 01/19] fix: Update Canvas Provider theming --- modules/react/common/lib/CanvasProvider.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/modules/react/common/lib/CanvasProvider.tsx b/modules/react/common/lib/CanvasProvider.tsx index 9598e60c6a..3d4ec876b6 100644 --- a/modules/react/common/lib/CanvasProvider.tsx +++ b/modules/react/common/lib/CanvasProvider.tsx @@ -214,10 +214,19 @@ export const useCanvasThemeToCssVars = ( const style = elemProps.style || {}; const {palette} = filledTheme.canvas; + // A consumer who passes any `palette` (partial or the full `defaultCanvasTheme`) is + // intentionally scoping/resetting branding for this subtree, opting out of global CSS + // theming (e.g. a `data-theme` attribute). In that case we write every branded token from + // the filled theme, even ones that resolve to the same value as `defaultCanvasTheme`, so the + // override actually wins over the global cascade. If no `palette` was passed, we write + // nothing and let global theming (and `defaultBranding` as a last resort) take over. + const hasExplicitPalette = + !!theme?.canvas?.palette && Object.keys(theme.canvas.palette).length > 0; + (['common', 'primary', 'error', 'alert', 'success', 'neutral'] as const).forEach(color => { if (color === 'common') { (['focusOutline', 'alertInner', 'alertOuter', 'errorInner'] as const).forEach(key => { - if (palette.common[key] !== defaultCanvasTheme.palette.common[key]) { + if (hasExplicitPalette) { const value = maybeWrapCSSVariables(palette.common[key]); // Set deprecated token for backwards compatibility @@ -274,9 +283,10 @@ export const useCanvasThemeToCssVars = ( } else { (['lightest', 'lighter', 'light', 'main', 'dark', 'darkest', 'contrast'] as const).forEach( key => { - // We only want to set custom colors if they do not match the default. The `defaultBranding` class will take care of the rest. - //@ts-ignore - if (palette[color][key] !== defaultCanvasTheme.palette[color][key]) { + // Only force an override when the consumer explicitly passed a `palette` (see + // `hasExplicitPalette` above). Otherwise leave these tokens alone so global CSS + // theming (e.g. a `data-theme` attribute) and `defaultBranding` continue to cascade. + if (hasExplicitPalette) { const value = maybeWrapCSSVariables(palette[color][key]); // Set deprecated token (e.g., brand.primary.base) for backwards compatibility From c1ee9103f5de464f6b2c647b86f2ab8622affbc9 Mon Sep 17 00:00:00 2001 From: "manuel.carrera" Date: Fri, 17 Jul 2026 14:35:47 -0600 Subject: [PATCH 02/19] fix: Ai did something --- .storybook/preview.js | 9 +- modules/react/common/lib/CanvasProvider.tsx | 414 ++++------------- .../react/common/lib/theming/brandScope.ts | 428 ++++++++++++++++++ modules/react/common/lib/theming/index.ts | 2 + modules/react/common/lib/theming/sanaTheme.ts | 107 +++++ modules/react/common/lib/theming/types.ts | 306 +++++++++++++ modules/react/common/spec/brandScope.spec.ts | 31 ++ modules/react/common/spec/sanaTheme.spec.ts | 22 + .../react/common/spec/theming-types.spec.ts | 54 +++ .../spec/useCanvasThemeToCssVars.spec.tsx | 37 ++ modules/react/common/stories/mdx/Theming.mdx | 108 +++++ .../common/stories/mdx/Theming.stories.tsx | 5 + .../mdx/examples/ThemingBrandScope.tsx | 27 ++ .../theming/ThemeComparison.stories.tsx | 56 +++ .../theming/examples/BrandingFixture.tsx | 57 +++ .../react/popup/lib/hooks/usePopupStack.ts | 9 +- utils/storybook/CanvasProviderDecorator.tsx | 24 +- utils/storybook/customThemes.ts | 18 +- 18 files changed, 1368 insertions(+), 346 deletions(-) create mode 100644 modules/react/common/lib/theming/brandScope.ts create mode 100644 modules/react/common/lib/theming/sanaTheme.ts create mode 100644 modules/react/common/spec/brandScope.spec.ts create mode 100644 modules/react/common/spec/sanaTheme.spec.ts create mode 100644 modules/react/common/spec/theming-types.spec.ts create mode 100644 modules/react/common/spec/useCanvasThemeToCssVars.spec.tsx create mode 100644 modules/react/common/stories/mdx/examples/ThemingBrandScope.tsx create mode 100644 modules/react/common/stories/theming/ThemeComparison.stories.tsx create mode 100644 modules/react/common/stories/theming/examples/BrandingFixture.tsx diff --git a/.storybook/preview.js b/.storybook/preview.js index c205583779..ea5268fcec 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -5,8 +5,13 @@ import '@workday/canvas-tokens-web/css/base/_variables.css'; import '@workday/canvas-tokens-web/css/brand/_variables.css'; import '@workday/canvas-tokens-web/css/component/_variables.css'; import '@workday/canvas-tokens-web/css/system/_variables.css'; -// Imported after system so its equal-specificity `[data-theme="sana-canvas"]` rules win -// the cascade tie over system's unscoped `:root` rules when set on . +// Sana's `[data-theme="sana-canvas"]` selector and system/brand/base's `:root` +// selector have equal specificity (0,1,0). When both match the same element +// (i.e. `data-theme="sana-canvas"` is set on ), the cascade falls back +// to source order. Importing sana last is therefore required and sufficient — +// this is deterministic as long as each file is imported exactly once, in +// this order, by a single root entry point (true for both Storybook and any +// real consuming app). See the Theming docs for the guidance we give consumers. import '@workday/canvas-tokens-web/css/sana/_variables.css'; import {CanvasProviderDecorator} from '../utils/storybook'; diff --git a/modules/react/common/lib/CanvasProvider.tsx b/modules/react/common/lib/CanvasProvider.tsx index 3d4ec876b6..885a828fbf 100644 --- a/modules/react/common/lib/CanvasProvider.tsx +++ b/modules/react/common/lib/CanvasProvider.tsx @@ -1,10 +1,26 @@ import {CacheProvider, Theme, ThemeProvider} from '@emotion/react'; import * as React from 'react'; -import {createStyles, getCache, maybeWrapCSSVariables} from '@workday/canvas-kit-styling'; +import {createStyles, getCache} from '@workday/canvas-kit-styling'; import {base, brand, system} from '@workday/canvas-tokens-web'; -import {PartialEmotionCanvasTheme, defaultCanvasTheme, useTheme} from './theming'; +import { + CanvasProviderTheme, + CanvasThemingScope, + EmotionCanvasTheme, + PartialEmotionCanvasTheme, + defaultCanvasTheme, + getTheme, + isNumericalTheme, + resolveThemingScope, + useTheme, +} from './theming'; +import { + hasExplicitSemanticPalette, + writeBrandScopeSemantic, + writeNumericalTheme, + writeSemanticTheme, +} from './theming/brandScope'; export interface CanvasProviderProps { /** @@ -13,105 +29,22 @@ export interface CanvasProviderProps { * * While we support theme overrides, we advise to use global theming via CSS Variables. */ - theme?: PartialEmotionCanvasTheme; + theme?: CanvasProviderTheme; + /** + * How partial theme input expands. Default `'brand'`. + * + * **Numerical `brand` shape:** `'brand'` applies the `primary['600']` shortcut + * (PrimaryButton + selected states). `'full'` writes each ramp key literally with + * no shortcuts. Other `brand.*` keys always map 1:1 to CSS variables. + * + * **Legacy `canvas.palette` shape:** `'brand'` = primary.main shortcut only. + * `'full'` = auto-generated ramps + broad `system.color.brand.*` forwarding. + * + * @default 'brand' + */ + themeScope?: CanvasThemingScope; } -const mappedKeys = { - lightest: 'lightest', - lighter: 'lighter', - light: 'light', - main: 'base', - dark: 'dark', - darkest: 'darkest', - contrast: 'accent', -}; - -/** - * Mapping from deprecated theme palette keys to new numerical brand tokens. - * This ensures backwards compatibility when consumers use the old theme format. - * For example: palette.primary.main -> brand.primary600 - */ -const numericalTokenMapping = { - lightest: '25', - lighter: '50', - light: '200', - main: '600', - dark: '700', - darkest: '800', -} as const; - -/** - * Mapping from deprecated theme palette colors to new brand token names. - * For example: - * `primary` -> `primary` - * `error` -> `critical` - * `success` -> `positive` - * `alert` -> `caution` - * `neutral` -> `neutral` - */ -const brandColorMapping = { - primary: 'primary', - error: 'critical', - success: 'positive', - alert: 'caution', - neutral: 'neutral', -} as const; - -/** - * Mapping from deprecated common palette keys to new brand.common tokens. - * - * ## Brandable System Tokens - * - * These are all the `system.color.brand.*` tokens that can be customized via theming. - * Each token references a brand token that can be overridden through the CanvasProvider theme prop. - * - * ### Focus Tokens - * - `system.color.brand.focus.primary` → `brand.primary.500` → Controlled by `focusOutline` (separately from `palette.primary.main`, which controls `brand.primary.600`) - * - `system.color.brand.focus.critical` → `brand.critical.500` → Controlled by `palette.error.dark` or `errorInner` - * - `system.color.brand.focus.caution.inner` → `brand.caution.400` → Controlled by `palette.alert.main` or `alertInner` - * - `system.color.brand.focus.caution.outer` → `brand.caution.500` → Controlled by `palette.alert.dark` or `alertOuter` - * - * ### Border Tokens - * - `system.color.brand.border.primary` → `brand.primary.500` → Controlled by `focusOutline` (separately from `palette.primary.main`, which controls `brand.primary.600`) - * - `system.color.brand.border.critical` → `brand.critical.500` → Controlled by `palette.error.dark` or `errorInner` - * - `system.color.brand.border.caution` → `brand.caution.500` → Controlled by `palette.alert.dark` or `alertOuter` - * - * ### Surface Tokens - * - `system.color.brand.surface.primary.default` → `brand.primary.A25` → Controlled by `palette.primary.lightest` - * - `system.color.brand.surface.primary.strong` → `brand.primary.A50` → Controlled by `palette.primary.lighter` - * - `system.color.brand.surface.critical.default` → `brand.critical.A25` → Controlled by `palette.error.lightest` - * - `system.color.brand.surface.critical.strong` → `brand.critical.A50` → Controlled by `palette.error.lighter` - * - `system.color.brand.surface.caution.default` → `brand.caution.A25` → Controlled by `palette.alert.lightest` - * - `system.color.brand.surface.caution.strong` → `brand.caution.A50` → Controlled by `palette.alert.lighter` - * - `system.color.brand.surface.positive.default` → `brand.positive.A25` → Controlled by `palette.success.lightest` - * - `system.color.brand.surface.positive.strong` → `brand.positive.A50` → Controlled by `palette.success.lighter` - * - `system.color.brand.surface.selected` → `brand.primary.A50` → Controlled by `palette.primary.lighter` - * - * ### Accent Tokens - * - `system.color.brand.accent.primary` → `brand.primary.600` → Controlled by `palette.primary.main` - * - `system.color.brand.accent.critical` → `brand.critical.600` → Controlled by `palette.error.main` - * - `system.color.brand.accent.caution` → `brand.caution.400` → Controlled by `palette.alert.main` - * - `system.color.brand.accent.positive` → `brand.positive.600` → Controlled by `palette.success.main` - * - `system.color.brand.accent.action` → `brand.primary.600` → Controlled by `palette.primary.main` - * - * ### Foreground (Text/Icon) Tokens - * - `system.color.brand.fg.primary.default` → `brand.primary.600` → Controlled by `palette.primary.main` - * - `system.color.brand.fg.primary.strong` → `brand.primary.700` → Controlled by `palette.primary.dark` - * - `system.color.brand.fg.critical.default` → `brand.critical.600` → Controlled by `palette.error.main` - * - `system.color.brand.fg.critical.strong` → `brand.critical.700` → Controlled by `palette.error.dark` - * - `system.color.brand.fg.caution.default` → `brand.caution.600` → Controlled by `palette.alert.darkest` - * - `system.color.brand.fg.caution.strong` → `brand.caution.700` → Controlled by `palette.alert.dark` (Note: no direct mapping, inherits default) - * - `system.color.brand.fg.positive.default` → `brand.positive.600` → Controlled by `palette.success.main` - * - `system.color.brand.fg.positive.strong` → `brand.positive.700` → Controlled by `palette.success.dark` - * - `system.color.brand.fg.selected` → `brand.primary.700` → Controlled by `palette.primary.dark` - */ -const commonTokenMapping = { - focusOutline: brand.common.focus, // maps to brand.primary500 - alertInner: brand.common.caution.inner, // maps to brand.caution400 - alertOuter: brand.common.caution.outer, // maps to brand.caution500 - errorInner: brand.common.critical, // maps to brand.critical500 -} as const; - /** * If you wish to reset the theme to the default, apply this class on the CanvasProvider. */ @@ -158,12 +91,6 @@ export const defaultBranding = createStyles({ [brand.gradient.primary]: `linear-gradient(90deg, ${brand.primary.base} 0%, ${brand.primary.dark} 100%)`, - /** - * Default `system.color.brand.*` values on the provider scope so components that read - * system tokens first (e.g. `cssVar(system.color.brand.accent.primary, brand.primary.base)`) - * still resolve to the same branding as legacy `brand.*` vars when globals define system - * tokens differently, or when consumers override `brand.*` via `className`. - */ [system.color.brand.focus.primary]: brand.common.focusOutline, [system.color.brand.border.primary]: brand.common.focusOutline, [system.color.brand.accent.primary]: brand.primary.base, @@ -202,245 +129,80 @@ export const defaultBranding = createStyles({ [system.color.brand.surface.selected]: brand.primary.lighter, }); +/** Pure function — safe to call outside React hooks (e.g. usePopupStack). */ +export function canvasThemeToCssVars( + theme: CanvasProviderTheme | undefined, + elemProps: React.HTMLAttributes, + options?: { + themeScope?: CanvasThemingScope; + filledSemanticTheme?: EmotionCanvasTheme; + } +) { + const className = elemProps.className || ''; + const style: React.CSSProperties = {...(elemProps.style || {})}; + const scope = options?.themeScope ?? resolveThemingScope(theme); + + if (!theme) { + return {...elemProps, className, style}; + } + + if (isNumericalTheme(theme)) { + writeNumericalTheme(theme, style, scope); + } else if (!hasExplicitSemanticPalette(theme)) { + // `{canvas: {}}` — no inline overrides; global CSS (e.g. Sana) cascades through. + } else if (scope === 'brand') { + writeBrandScopeSemantic(theme, style); + } else { + const filledTheme = + options?.filledSemanticTheme ?? getTheme(theme as PartialEmotionCanvasTheme); + writeSemanticTheme(filledTheme.canvas.palette, style, {writeAll: true}); + } + + return {...elemProps, className, style}; +} + export const useCanvasThemeToCssVars = ( /** * @deprecated ⚠️ `theme` is deprecated. In previous versions of Canvas Kit, we allowed teams to pass a theme object, this supported [Emotion's theming](https://emotion.sh/docs/theming). Now that we're shifting to a global theming approach based on CSS variables, we advise to no longer using the theme prop. For more information, view our [Theming Docs](https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs#-preferred-approach-v14). */ - theme: PartialEmotionCanvasTheme | undefined, - elemProps: React.HTMLAttributes + theme: CanvasProviderTheme | undefined, + elemProps: React.HTMLAttributes, + themeScope?: CanvasThemingScope ) => { - const filledTheme = useTheme(theme); - const className = (elemProps.className || '').split(' ').concat(defaultBranding).join(' '); - const style = elemProps.style || {}; - const {palette} = filledTheme.canvas; - - // A consumer who passes any `palette` (partial or the full `defaultCanvasTheme`) is - // intentionally scoping/resetting branding for this subtree, opting out of global CSS - // theming (e.g. a `data-theme` attribute). In that case we write every branded token from - // the filled theme, even ones that resolve to the same value as `defaultCanvasTheme`, so the - // override actually wins over the global cascade. If no `palette` was passed, we write - // nothing and let global theming (and `defaultBranding` as a last resort) take over. - const hasExplicitPalette = - !!theme?.canvas?.palette && Object.keys(theme.canvas.palette).length > 0; - - (['common', 'primary', 'error', 'alert', 'success', 'neutral'] as const).forEach(color => { - if (color === 'common') { - (['focusOutline', 'alertInner', 'alertOuter', 'errorInner'] as const).forEach(key => { - if (hasExplicitPalette) { - const value = maybeWrapCSSVariables(palette.common[key]); - - // Set deprecated token for backwards compatibility - //@ts-ignore - style[brand.common[key]] = value; - - // Forward to new brand.common tokens - //@ts-ignore - style[commonTokenMapping[key]] = value; - - // Additional system token forwarding for focusOutline - if (key === 'focusOutline') { - // system.color.brand.focus.primary -> brand.primary.500 (via brand.common.focus) - // @ts-ignore - style[system.color.brand.focus.primary] = value; - // system.color.brand.border.primary -> brand.primary.500 - // @ts-ignore - style[system.color.brand.border.primary] = value; - } - - // Additional system token forwarding for alertInner - if (key === 'alertInner') { - // Forward alertInner to system.color.brand.focus.caution.inner - // This token is used by components (e.g., TextInput with error="caution") - // for inner focus ring styling. Maps to brand.caution400 via brand.common.caution.inner. - // This ensures backwards compatibility when users customize alertInner in their theme. - // @ts-ignore - style[system.color.brand.focus.caution.inner] = value; - } - - // Additional system token forwarding for alertOuter - if (key === 'alertOuter') { - // Forward alertOuter to system.color.brand.border.caution - // This token is used by components (e.g., TextInput with error="caution") - // for border and outer focus ring styling. Maps to brand.caution500 via brand.common.caution.outer. - // This ensures backwards compatibility when users customize alertOuter in their theme. - // @ts-ignore - style[system.color.brand.border.caution] = value; - } - - // Additional system token forwarding for errorInner - if (key === 'errorInner') { - // Forward errorInner to system.color.brand.focus.critical and system.color.brand.border.critical - // These tokens are used by components (e.g., TextInput with error="error", Switch) for critical - // focus ring and border styling. Maps to brand.critical500 via brand.common.errorInner. - // This ensures backwards compatibility when users customize errorInner in their theme. - // @ts-ignore - style[system.color.brand.focus.critical] = value; - // @ts-ignore - style[system.color.brand.border.critical] = value; - } - } - }); - } else { - (['lightest', 'lighter', 'light', 'main', 'dark', 'darkest', 'contrast'] as const).forEach( - key => { - // Only force an override when the consumer explicitly passed a `palette` (see - // `hasExplicitPalette` above). Otherwise leave these tokens alone so global CSS - // theming (e.g. a `data-theme` attribute) and `defaultBranding` continue to cascade. - if (hasExplicitPalette) { - const value = maybeWrapCSSVariables(palette[color][key]); - - // Set deprecated token (e.g., brand.primary.base) for backwards compatibility - // @ts-ignore - style[brand[color][mappedKeys[key]]] = value; - - // Forward to new numerical brand tokens (e.g., brand.primary600) - // Skip 'contrast' as it doesn't map to numerical tokens - if (key !== 'contrast' && key in numericalTokenMapping) { - const newBrandColor = brandColorMapping[color as keyof typeof brandColorMapping]; - const numericalSuffix = - numericalTokenMapping[key as keyof typeof numericalTokenMapping]; - - // @ts-ignore - Dynamically access brand tokens like brand.primary600 - const numericalToken = brand[newBrandColor + numericalSuffix]; - if (numericalToken) { - // @ts-ignore - style[numericalToken] = value; - } - - // Forward to all relevant system.color.brand.* tokens - // These system tokens reference the numerical brand tokens, so updating them ensures full compatibility - if (key === 'main') { - // system.color.brand.accent.{color} -> brand.{color}.600 (except caution -> 400) - // @ts-ignore - const systemAccentToken = system.color.brand.accent[newBrandColor]; - if (systemAccentToken) { - // @ts-ignore - style[systemAccentToken] = value; - } - - // system.color.brand.fg.{color}.default -> brand.{color}.600 - // @ts-ignore - const systemFgToken = system.color.brand.fg[newBrandColor]?.default; - if (systemFgToken) { - // @ts-ignore - style[systemFgToken] = value; - } - - // system.color.brand.focus.primary (maps to brand.primary.500 per docs) - // For primary only, update focus when 'main' changes — unless focusOutline was customized (it takes precedence) - if (newBrandColor === 'primary') { - const focusOutlineCustomized = - palette.common.focusOutline !== defaultCanvasTheme.palette.common.focusOutline; - if (!focusOutlineCustomized) { - // @ts-ignore - const focusToken = system.color.brand.focus.primary; - if (focusToken) { - // @ts-ignore - style[focusToken] = value; - } - } - } - } else if (key === 'dark') { - // system.color.brand.fg.{color}.strong -> brand.{color}.700 - // @ts-ignore - const systemFgStrongToken = system.color.brand.fg[newBrandColor]?.strong; - if (systemFgStrongToken) { - // @ts-ignore - style[systemFgStrongToken] = value; - } - - // system.color.brand.fg.selected -> brand.primary.700 (for primary only) - if (newBrandColor === 'primary') { - // @ts-ignore - const selectedToken = system.color.brand.fg.selected; - if (selectedToken) { - // @ts-ignore - style[selectedToken] = value; - } - } - - // system.color.brand.focus.critical & system.color.brand.border.critical -> brand.critical.500 (palette.error.dark) - if (newBrandColor === 'critical') { - // @ts-ignore - const focusCriticalToken = system.color.brand.focus.critical; - if (focusCriticalToken) { - // @ts-ignore - style[focusCriticalToken] = value; - } - // @ts-ignore - const borderCriticalToken = system.color.brand.border.critical; - if (borderCriticalToken) { - // @ts-ignore - style[borderCriticalToken] = value; - } - } - - // system.color.brand.focus.caution.outer & system.color.brand.border.caution -> brand.caution.500 (palette.alert.dark) - if (newBrandColor === 'caution') { - // @ts-ignore - const focusCautionOuterToken = system.color.brand.focus.caution?.outer; - if (focusCautionOuterToken) { - // @ts-ignore - style[focusCautionOuterToken] = value; - } - // @ts-ignore - const borderCautionToken = system.color.brand.border.caution; - if (borderCautionToken) { - // @ts-ignore - style[borderCautionToken] = value; - } - } - } else if (key === 'lighter') { - // system.color.brand.surface.{color}.strong -> brand.{color}.A50 - // Note: A50 tokens are different from regular 50 tokens but we'll forward the lighter value - // @ts-ignore - const surfaceStrongToken = system.color.brand.surface[newBrandColor]?.strong; - if (surfaceStrongToken) { - // @ts-ignore - style[surfaceStrongToken] = value; - } + const filledTheme = useTheme( + isNumericalTheme(theme) ? undefined : (theme as PartialEmotionCanvasTheme) + ); + const resolvedScope = themeScope ?? resolveThemingScope(theme); - // system.color.brand.surface.selected -> brand.primary.A50 (for primary only) - if (newBrandColor === 'primary') { - // @ts-ignore - const selectedSurfaceToken = system.color.brand.surface.selected; - if (selectedSurfaceToken) { - // @ts-ignore - style[selectedSurfaceToken] = value; - } - } - } else if (key === 'lightest') { - // system.color.brand.surface.{color}.default -> brand.{color}.A25 - // @ts-ignore - const surfaceDefaultToken = system.color.brand.surface[newBrandColor]?.default; - if (surfaceDefaultToken) { - // @ts-ignore - style[surfaceDefaultToken] = value; - } - } - } - } - } - ); - } + return canvasThemeToCssVars(theme, elemProps, { + themeScope: resolvedScope, + filledSemanticTheme: + !isNumericalTheme(theme) && resolvedScope === 'full' ? filledTheme : undefined, }); - - return {...elemProps, className, style}; }; export const CanvasProvider = ({ children, - theme = {canvas: {}}, // default to empty theme to avoid breaking changes + theme = {canvas: {}}, + themeScope, ...props }: CanvasProviderProps & React.HTMLAttributes) => { - const {className, ...elemProps} = useCanvasThemeToCssVars(theme, props); + const {className, ...elemProps} = useCanvasThemeToCssVars(theme, props, themeScope); const cache = getCache(); const rest = {...elemProps, ...props}; + const emotionTheme = isNumericalTheme(theme) + ? ({canvas: defaultCanvasTheme} as Theme) + : (theme as Theme); + return ( - +
)} > {children} diff --git a/modules/react/common/lib/theming/brandScope.ts b/modules/react/common/lib/theming/brandScope.ts new file mode 100644 index 0000000000..e9c3b126fa --- /dev/null +++ b/modules/react/common/lib/theming/brandScope.ts @@ -0,0 +1,428 @@ +import * as React from 'react'; + +import {colorSpace, maybeWrapCSSVariables} from '@workday/canvas-kit-styling'; +import {brand, system} from '@workday/canvas-tokens-web'; + +import {defaultCanvasTheme} from './theme'; +import { + CanvasBrandRamp, + CanvasNumericalBrandTheme, + CanvasProviderTheme, + CanvasTheme, + PartialEmotionCanvasTheme, + isNumericalTheme, +} from './types'; + +/** True when a semantic theme intentionally passes a non-empty `canvas.palette`. */ +export function hasExplicitSemanticPalette( + theme: CanvasProviderTheme +): theme is PartialEmotionCanvasTheme { + return ( + !isNumericalTheme(theme) && + !!theme.canvas?.palette && + Object.keys(theme.canvas.palette).length > 0 + ); +} + +/** Tokens that change when a consumer sets only their primary brand color. */ +export const BRAND_SCOPE_PRIMARY_BUNDLE = { + action: ['base', 'dark', 'darkest', 'accent'] as const, + accent: ['primary', 'action'] as const, + selected: { + fg: system.color.brand.fg.selected, + surface: system.color.brand.surface.selected, + }, +} as const; + +/** + * Independently brandable tokens — writable when explicitly provided, + * but NEVER derived from primary.main / brand.primary.600. + */ +export const BRAND_SCOPE_INDEPENDENT = { + focus: { + primary: system.color.brand.focus.primary, + brandToken: brand.primary500, + semanticKey: 'focusOutline' as const, + }, + border: { + primary: system.color.brand.border.primary, + brandToken: brand.primary500, + semanticKey: 'focusOutline' as const, + }, +} as const; + +const commonTokenMapping = { + focusOutline: brand.common.focus, + alertInner: brand.common.caution.inner, + alertOuter: brand.common.caution.outer, + errorInner: brand.common.critical, +} as const; + +type BrandColor = 'primary' | 'critical' | 'caution' | 'positive' | 'neutral'; + +const brandColorMapping: Record = { + primary: 'primary', + error: 'critical', + success: 'positive', + alert: 'caution', + neutral: 'neutral', +}; + +const setStyleVar = (style: React.CSSProperties, token: string, value: string) => { + // @ts-ignore - CSS custom property key + style[token] = maybeWrapCSSVariables(value); +}; + +/** Maps a numerical brand ramp onto `brand.` CSS variables. */ +export function writeNumericalBrandRamp( + color: BrandColor | 'action', + ramp: CanvasBrandRamp | undefined, + style: React.CSSProperties, + options?: {skipKeys?: Set} +) { + if (!ramp) { + return; + } + (Object.keys(ramp) as Array).forEach(rampKey => { + if (options?.skipKeys?.has(rampKey)) { + return; + } + const value = ramp[rampKey]; + if (value == null) { + return; + } + const token = + color === 'action' + ? brand.action[rampKey as keyof typeof brand.action] + : // @ts-ignore - dynamic token lookup + brand[`${color}${rampKey}`]; + if (token) { + setStyleVar(style, token, value); + } + }); +} + +/** Called when consumer sets only `primary.main` or `brand.primary['600']`. */ +export function applyPrimaryBrandBundle(primaryColor: string, style: React.CSSProperties) { + const value = maybeWrapCSSVariables(primaryColor); + + setStyleVar(style, brand.primary.base, value); + setStyleVar(style, brand.primary600, value); + setStyleVar(style, brand.action.base, value); + + if (system.color.brand.accent.primary) { + setStyleVar(style, system.color.brand.accent.primary, value); + } + if (system.color.brand.accent.action) { + setStyleVar(style, system.color.brand.accent.action, value); + } + + if (BRAND_SCOPE_PRIMARY_BUNDLE.selected.fg) { + setStyleVar(style, BRAND_SCOPE_PRIMARY_BUNDLE.selected.fg, `var(${brand.primary700})`); + } + if (BRAND_SCOPE_PRIMARY_BUNDLE.selected.surface) { + setStyleVar(style, BRAND_SCOPE_PRIMARY_BUNDLE.selected.surface, `var(${brand.primaryA50})`); + } + + const hoverColor = colorSpace.hover({ + color: `var(${brand.action.base})`, + fallback: value, + colorType: 'accent', + }); + const pressedColor = colorSpace.pressed({ + color: `var(${brand.action.base})`, + fallback: value, + colorType: 'accent', + }); + setStyleVar(style, brand.action.dark, hoverColor); + setStyleVar(style, brand.action.darkest, pressedColor); +} + +/** Writes first-class selected shortcuts from numerical theme input. */ +export function writeSelectedShortcuts( + selected: CanvasNumericalBrandTheme['selected'] | undefined, + style: React.CSSProperties +) { + if (!selected) { + return; + } + if (selected.fg && BRAND_SCOPE_PRIMARY_BUNDLE.selected.fg) { + setStyleVar(style, BRAND_SCOPE_PRIMARY_BUNDLE.selected.fg, selected.fg); + } + if (selected.surface && BRAND_SCOPE_PRIMARY_BUNDLE.selected.surface) { + setStyleVar(style, BRAND_SCOPE_PRIMARY_BUNDLE.selected.surface, selected.surface); + } +} + +function writeFocusBorderBundle(value: string, style: React.CSSProperties) { + setStyleVar(style, brand.common.focusOutline, value); + setStyleVar(style, brand.primary500, value); + if (BRAND_SCOPE_INDEPENDENT.focus.primary) { + setStyleVar(style, BRAND_SCOPE_INDEPENDENT.focus.primary, value); + } + if (BRAND_SCOPE_INDEPENDENT.border.primary) { + setStyleVar(style, BRAND_SCOPE_INDEPENDENT.border.primary, value); + } +} + +/** Writes focus/border from explicit common or brand.primary.500 input — never from primary.main. */ +export function writeIndependentBrandTokens( + theme: CanvasProviderTheme | undefined, + style: React.CSSProperties +) { + if (!theme) { + return; + } + + if (isNumericalTheme(theme)) { + const focusValue = theme.brand?.primary?.['500']; + if (focusValue) { + writeFocusBorderBundle(focusValue, style); + } + if (theme.system) { + writeSystemBrandOverrides(theme.system, style); + } + return; + } + + const common = theme.canvas?.palette?.common; + if (!common) { + return; + } + + (['focusOutline', 'alertInner', 'alertOuter', 'errorInner'] as const).forEach(key => { + const rawValue = common[key]; + if (rawValue == null) { + return; + } + if (rawValue === defaultCanvasTheme.palette.common[key]) { + return; + } + + const value = maybeWrapCSSVariables(rawValue); + // @ts-ignore + setStyleVar(style, brand.common[key], value); + // @ts-ignore + setStyleVar(style, commonTokenMapping[key], value); + + if (key === 'focusOutline') { + writeFocusBorderBundle(value, style); + } + if (key === 'alertInner' && system.color.brand.focus.caution?.inner) { + setStyleVar(style, system.color.brand.focus.caution.inner, value); + } + if (key === 'alertOuter' && system.color.brand.border.caution) { + setStyleVar(style, system.color.brand.border.caution, value); + } + if (key === 'errorInner') { + if (system.color.brand.focus.critical) { + setStyleVar(style, system.color.brand.focus.critical, value); + } + if (system.color.brand.border.critical) { + setStyleVar(style, system.color.brand.border.critical, value); + } + } + }); +} + +/** Walks nested system override object and sets matching system CSS vars. */ +export function writeSystemBrandOverrides( + systemOverrides: {color?: {brand?: Record}} | undefined, + style: React.CSSProperties +) { + const brandOverrides = systemOverrides?.color?.brand; + if (!brandOverrides) { + return; + } + + const walk = (node: Record, path: string[]) => { + Object.entries(node).forEach(([key, val]) => { + if (val && typeof val === 'object') { + walk(val as Record, [...path, key]); + } else if (typeof val === 'string') { + let tokenNode: unknown = system.color.brand; + for (const seg of [...path, key]) { + // @ts-ignore + tokenNode = tokenNode?.[seg]; + } + if (typeof tokenNode === 'string') { + const cssVarName = tokenNode.match(/--[\w-]+/)?.[0] ?? tokenNode; + setStyleVar(style, cssVarName, val); + } + } + }); + }; + walk(brandOverrides, []); +} + +const mappedKeys = { + lightest: 'lightest', + lighter: 'lighter', + light: 'light', + main: 'base', + dark: 'dark', + darkest: 'darkest', + contrast: 'accent', +} as const; + +const numericalTokenMapping = { + lightest: '25', + lighter: '50', + light: '200', + main: '600', + dark: '700', + darkest: '800', +} as const; + +/** Full-scope semantic writer — filled palette compared against defaults unless `writeAll`. */ +export function writeSemanticTheme( + palette: CanvasTheme['palette'], + style: React.CSSProperties, + options?: {writeAll?: boolean} +) { + const writeAll = options?.writeAll ?? false; + + (['common', 'primary', 'error', 'alert', 'success', 'neutral'] as const).forEach(color => { + if (color === 'common') { + (['focusOutline', 'alertInner', 'alertOuter', 'errorInner'] as const).forEach(key => { + if (writeAll || palette.common[key] !== defaultCanvasTheme.palette.common[key]) { + const value = maybeWrapCSSVariables(palette.common[key]); + // @ts-ignore + setStyleVar(style, brand.common[key], value); + // @ts-ignore + setStyleVar(style, commonTokenMapping[key], value); + + if (key === 'focusOutline') { + writeFocusBorderBundle(value, style); + } + if (key === 'alertInner' && system.color.brand.focus.caution?.inner) { + setStyleVar(style, system.color.brand.focus.caution.inner, value); + } + if (key === 'alertOuter' && system.color.brand.border.caution) { + setStyleVar(style, system.color.brand.border.caution, value); + } + if (key === 'errorInner') { + if (system.color.brand.focus.critical) { + setStyleVar(style, system.color.brand.focus.critical, value); + } + if (system.color.brand.border.critical) { + setStyleVar(style, system.color.brand.border.critical, value); + } + } + } + }); + } else { + (['lightest', 'lighter', 'light', 'main', 'dark', 'darkest', 'contrast'] as const).forEach( + key => { + // @ts-ignore + if (writeAll || palette[color][key] !== defaultCanvasTheme.palette[color][key]) { + // @ts-ignore + const value = maybeWrapCSSVariables(palette[color][key]); + // @ts-ignore + setStyleVar(style, brand[color][mappedKeys[key]], value); + + if (key !== 'contrast' && key in numericalTokenMapping) { + const newBrandColor = brandColorMapping[color]; + const numericalSuffix = + numericalTokenMapping[key as keyof typeof numericalTokenMapping]; + // @ts-ignore + const numericalToken = brand[newBrandColor + numericalSuffix]; + if (numericalToken) { + setStyleVar(style, numericalToken, value); + } + + if (key === 'main') { + // @ts-ignore + const systemAccentToken = system.color.brand.accent[newBrandColor]; + if (systemAccentToken) { + setStyleVar(style, systemAccentToken, value); + } + // @ts-ignore + const systemFgToken = system.color.brand.fg[newBrandColor]?.default; + if (systemFgToken) { + setStyleVar(style, systemFgToken, value); + } + } else if (key === 'dark') { + // @ts-ignore + const systemFgStrongToken = system.color.brand.fg[newBrandColor]?.strong; + if (systemFgStrongToken) { + setStyleVar(style, systemFgStrongToken, value); + } + if (newBrandColor === 'primary' && system.color.brand.fg.selected) { + setStyleVar(style, system.color.brand.fg.selected, value); + } + if (newBrandColor === 'critical') { + if (system.color.brand.focus.critical) { + setStyleVar(style, system.color.brand.focus.critical, value); + } + if (system.color.brand.border.critical) { + setStyleVar(style, system.color.brand.border.critical, value); + } + } + if (newBrandColor === 'caution') { + if (system.color.brand.focus.caution?.outer) { + setStyleVar(style, system.color.brand.focus.caution.outer, value); + } + if (system.color.brand.border.caution) { + setStyleVar(style, system.color.brand.border.caution, value); + } + } + } else if (key === 'lighter') { + // @ts-ignore + const surfaceStrongToken = system.color.brand.surface[newBrandColor]?.strong; + if (surfaceStrongToken) { + setStyleVar(style, surfaceStrongToken, value); + } + if (newBrandColor === 'primary' && system.color.brand.surface.selected) { + setStyleVar(style, system.color.brand.surface.selected, value); + } + } else if (key === 'lightest') { + // @ts-ignore + const surfaceDefaultToken = system.color.brand.surface[newBrandColor]?.default; + if (surfaceDefaultToken) { + setStyleVar(style, surfaceDefaultToken, value); + } + } + } + } + } + ); + } + }); +} + +/** Writes numerical theme — brand scope applies primary shortcut; full scope is literal 1:1 only. */ +export function writeNumericalTheme( + theme: CanvasNumericalBrandTheme, + style: React.CSSProperties, + scope: 'brand' | 'full' +) { + const primaryRamp = theme.brand?.primary; + const primaryOnly = + scope === 'brand' && primaryRamp?.['600'] && Object.keys(primaryRamp).length === 1; + + if (primaryOnly && primaryRamp?.['600']) { + applyPrimaryBrandBundle(primaryRamp['600'], style); + } else if (theme.brand) { + (['primary', 'critical', 'caution', 'positive', 'neutral', 'action'] as const).forEach( + color => { + writeNumericalBrandRamp(color, theme.brand?.[color], style); + } + ); + } + + writeSelectedShortcuts(theme.selected, style); + writeIndependentBrandTokens(theme, style); +} + +/** Brand-scope semantic path — reads raw input only, not useTheme-filled palette. */ +export function writeBrandScopeSemantic( + theme: PartialEmotionCanvasTheme, + style: React.CSSProperties +) { + const rawMain = theme.canvas?.palette?.primary?.main; + if (rawMain) { + applyPrimaryBrandBundle(rawMain, style); + } + writeIndependentBrandTokens(theme, style); +} diff --git a/modules/react/common/lib/theming/index.ts b/modules/react/common/lib/theming/index.ts index 0277053d58..be0bd8834e 100644 --- a/modules/react/common/lib/theming/index.ts +++ b/modules/react/common/lib/theming/index.ts @@ -14,6 +14,8 @@ export {default as styled, type StyleRewriteFn, filterOutProps} from './styled'; * For more information, view our [Theming Docs](https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs#-preferred-approach-v14). */ export * from './theme'; +export * from './sanaTheme'; +export * from './brandScope'; /** * @deprecated ⚠️ `useTheme` and `getTheme` are deprecated. Use CSS variables from `@workday/canvas-tokens-web` instead. * For more information, view our [Theming Docs](https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs#-preferred-approach-v14). diff --git a/modules/react/common/lib/theming/sanaTheme.ts b/modules/react/common/lib/theming/sanaTheme.ts new file mode 100644 index 0000000000..b92f9f1772 --- /dev/null +++ b/modules/react/common/lib/theming/sanaTheme.ts @@ -0,0 +1,107 @@ +/** + * Sana Canvas theme presets. + * + * Sana is a distinct theme from classic Canvas — not a delta on `defaultCanvasTheme`. + * Full visual treatment (fonts, shapes, system surfaces) comes from global CSS: + * import `@workday/canvas-tokens-web/css/sana/_variables.css` and set + * `data-theme="sana-canvas"` on ``. + * + * These JS presets reference Sana brand CSS variables so `CanvasProvider` can forward + * them to popup containers (menus, selects, modals). Keep in sync with + * `@workday/canvas-tokens-web/css/sana/_variables.css`. + * + * | Preset | Use case | + * |--------|----------| + * | `sanaCanvasNumericalTheme` | Numerical `brand` shape for popup forwarding | + * | `sanaCanvasProviderTheme` | Same — pass to root `CanvasProvider` with global Sana CSS | + */ +import {brand} from '@workday/canvas-tokens-web'; + +import type {CanvasNumericalBrandTheme} from './types'; + +/** Reference a canvas-tokens CSS variable (resolves under `[data-theme="sana-canvas"]`). */ +const varRef = (token: string) => `var(${token})`; + +/** + * Sana Canvas brand tokens for scoped `CanvasProvider` / popup forwarding. + * Values are `var()` references to Sana brand variables — not merged from `defaultCanvasTheme`. + */ +export const sanaCanvasNumericalTheme: CanvasNumericalBrandTheme = { + themeScope: 'full', + brand: { + action: { + base: varRef(brand.neutral975), + dark: varRef(brand.neutral950), + darkest: varRef(brand.neutral900), + accent: varRef(brand.neutral0), + lightest: varRef(brand.neutral25), + lighter: varRef(brand.neutral50), + light: varRef(brand.neutral200), + }, + neutral: { + '25': varRef(brand.neutral25), + '50': varRef(brand.neutral50), + '100': varRef(brand.neutral100), + '150': varRef(brand.neutral150), + '200': varRef(brand.neutral200), + '300': varRef(brand.neutral300), + '400': varRef(brand.neutral400), + '500': varRef(brand.neutral500), + '600': varRef(brand.neutral600), + '700': varRef(brand.neutral700), + '800': varRef(brand.neutral800), + '850': varRef(brand.neutral850), + '900': varRef(brand.neutral900), + '950': varRef(brand.neutral950), + '975': varRef(brand.neutral975), + A25: varRef(brand.neutralA25), + A50: varRef(brand.neutralA50), + A100: varRef(brand.neutralA100), + A150: varRef(brand.neutralA150), + A200: varRef(brand.neutralA200), + }, + primary: { + '500': varRef(brand.primary500), + '600': varRef(brand.primary600), + '700': varRef(brand.primary700), + A25: varRef(brand.primaryA25), + A50: varRef(brand.primaryA50), + A100: varRef(brand.primaryA100), + }, + critical: { + '500': varRef(brand.critical500), + '600': varRef(brand.critical600), + '700': varRef(brand.critical700), + A25: varRef(brand.criticalA25), + A50: varRef(brand.criticalA50), + }, + caution: { + '400': varRef(brand.caution400), + '500': varRef(brand.caution500), + A25: varRef(brand.cautionA25), + A50: varRef(brand.cautionA50), + }, + positive: { + '600': varRef(brand.positive600), + '800': varRef(brand.positive800), + A25: varRef(brand.positiveA25), + A50: varRef(brand.positiveA50), + }, + }, + selected: { + fg: varRef(brand.neutralA900), + surface: varRef(brand.neutralA100), + }, +}; + +/** + * Pass to `CanvasProvider` at app root when using global Sana CSS — forwards Sana brand + * variables to popup containers. + * + * @example + * ```tsx + * // index.css: import sana/_variables.css last; + * + * ``` + */ +export const sanaCanvasProviderTheme = sanaCanvasNumericalTheme; diff --git a/modules/react/common/lib/theming/types.ts b/modules/react/common/lib/theming/types.ts index 85bc504fe6..154acca332 100644 --- a/modules/react/common/lib/theming/types.ts +++ b/modules/react/common/lib/theming/types.ts @@ -281,3 +281,309 @@ declare module '@emotion/react' { * @deprecated ⚠️ `EmotionCanvasTheme` is deprecated. In previous versions of Canvas Kit, we allowed teams to pass a theme object, this supported [Emotion's theming](https://emotion.sh/docs/theming). Now that we're shifting to a global theming approach based on CSS variables, we advise to no longer using the theme prop. For more information, view our [Theming Docs](https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs#-preferred-approach-v14). */ export type EmotionCanvasTheme = {canvas: CanvasTheme}; + +/** + * Numerical brand ramp keys. Each key maps 1:1 to a `--cnvs-brand-{family}-{key}` CSS variable. + * + * Common keys: + * - `'600'` — main accent / button fill / default brand fg + * - `'700'` — strong fg / selected text (when not using `selected.fg`) + * - `'500'` — focus rings and border primary (independent of `'600'`) + * - `'A50'` — selected surface tint (when not using `selected.surface`) + * - `'25'` / `'A25'` — subtle brand surfaces + */ +export type CanvasBrandRamp = Partial< + Record< + | '25' + | '50' + | '100' + | '200' + | '300' + | '400' + | '500' + | '600' + | '700' + | '800' + | '900' + | '950' + | '975' + | 'A25' + | 'A50' + | 'A100' + | 'A200', + string + > +>; + +/** + * Controls how partial theme input is expanded. + * + * - `'brand'` (default): predictable, design-aligned behavior. Setting only + * `brand.primary['600']` themes PrimaryButton and selected list/menu + * states. Other keys write only their CSS variable — no auto-generated ramps. + * - `'full'`: legacy behavior for the deprecated `canvas.palette` shape — auto-fills + * lightest→darkest via `shiftColor` and forwards to many `system.color.brand.*` + * tokens. On the numerical `brand` shape, `'full'` disables the primary shortcut + * and writes each ramp key literally. + */ +export type CanvasThemingScope = 'brand' | 'full'; + +/** + * Preferred theme input for `CanvasProvider`. Each value maps directly to brand CSS + * variables unless noted as a shortcut below. + * + * @example Minimal — brand buttons + selected states only + * ```tsx + * + * ``` + * + * @example Explicit — focus independent of primary + * ```tsx + * + * ``` + * + * @see sanaCanvasProviderTheme for Sana global theme + popup parity + */ +export interface CanvasNumericalBrandTheme { + brand?: { + /** + * Primary brand ramp (`--cnvs-brand-primary-*`). + * + * **Shortcut (brand scope only):** when `'600'` is the only key under `primary`, + * also sets: + * - `PrimaryButton` — `brand.action.base`, `accent.primary`, `accent.action` + * - Selected `Menu.Item` — `system.color.brand.fg.selected`, `surface.selected` + * + * Does **not** set focus rings — use `'500'` or `canvas.palette.common.focusOutline`. + * + * | Key | CSS variable | Typical consumers | + * |-----|--------------|-------------------| + * | `'600'` | `--cnvs-brand-primary-600` | PrimaryButton, brand links, accent.primary | + * | `'700'` | `--cnvs-brand-primary-700` | Strong primary fg, selected text | + * | `'500'` | `--cnvs-brand-primary-500` | Focus rings, border primary | + * | `'A50'` | `--cnvs-brand-primary-A50` | Selected/hover surfaces | + */ + primary?: CanvasBrandRamp; + + /** + * Button-specific ramp (`--cnvs-brand-action-*`). PrimaryButton reads + * these **before** `brand.primary`. + * + * | Key | Typical consumers | + * |-----|-------------------| + * | `base` | PrimaryButton background | + * | `dark` / `darkest` | PrimaryButton hover / pressed | + * | `accent` | PrimaryButton label color | + */ + action?: CanvasBrandRamp; + + /** + * Critical/error ramp (`--cnvs-brand-critical-*`). + * + * | Key | Typical consumers | + * |-----|-------------------| + * | `'600'` | TextInput error, DeleteButton, critical fg | + * | `'500'` | Critical focus ring, error border | + * | `'A25'` / `'A50'` | Error surface tints | + */ + critical?: CanvasBrandRamp; + + /** + * Caution/warning ramp (`--cnvs-brand-caution-*`). + * + * | Key | Typical consumers | + * |-----|-------------------| + * | `'400'` | Caution accent, TextInput caution | + * | `'500'` | Caution focus outer, caution border | + * | `'A25'` / `'A50'` | Caution surface tints | + */ + caution?: CanvasBrandRamp; + + /** + * Positive/success ramp (`--cnvs-brand-positive-*`). + * + * | Key | Typical consumers | + * |-----|-------------------| + * | `'600'` | Checkbox/Radio checked, success fg | + * | `'A25'` / `'A50'` | Success surface tints | + */ + positive?: CanvasBrandRamp; + + /** + * Neutral brand ramp (`--cnvs-brand-neutral-*`). Sana Canvas uses `base.neutral*` + * instead of legacy `base.slate*` — see {@link sanaCanvasNumericalTheme}. + * + * Affects brand-neutral text, borders, and surfaces where components reference + * `brand.neutral.*` or `system.color.brand` tokens tied to neutral. + */ + neutral?: CanvasBrandRamp; + }; + + /** + * Selected-state shortcuts. Prefer these over indirect `brand.primary['700']` / + * `brand.primary.A50` when customizing list/menu selection. + * + * | Key | CSS variable | Typical consumers | + * |-----|--------------|-------------------| + * | `fg` | `--cnvs-sys-color-brand-fg-selected` | Menu.Item, SegmentedControl selected text | + * | `surface` | `--cnvs-sys-color-brand-surface-selected` | Menu.Item, list selected background | + */ + selected?: { + /** Selected text/icon color */ + fg?: string; + /** Selected row/chip background */ + surface?: string; + }; + + /** + * Escape hatch for `system.color.brand.*` tokens not covered by `brand` ramps. + * Keys mirror the token path, e.g. `{color: {brand: {focus: {primary: '#00f'}}}}`. + */ + system?: { + color?: { + brand?: Record; + }; + }; + + /** Text direction for the provider subtree. */ + direction?: ContentDirection; + + /** + * @default 'brand' + * @see CanvasThemingScope + */ + themeScope?: CanvasThemingScope; +} + +/** + * Theme input accepted by {@link CanvasProvider}. + * - Numerical `brand` shape (preferred) + * - Deprecated `canvas.palette` shape (legacy) + */ +export type CanvasProviderTheme = + | (PartialEmotionCanvasTheme & {themeScope?: CanvasThemingScope}) + | CanvasNumericalBrandTheme; + +export function isNumericalTheme( + theme: CanvasProviderTheme | undefined +): theme is CanvasNumericalBrandTheme { + if (!theme) { + return false; + } + if ('canvas' in theme) { + return false; + } + return 'brand' in theme || 'system' in theme || 'selected' in theme; +} + +const EXTENDED_RAMP_KEYS = new Set([ + '25', + '50', + '100', + '200', + '300', + '400', + '500', + '600', + '700', + '800', + '900', + '950', + '975', + 'A25', + 'A50', + 'A100', + 'A200', + 'lightest', + 'lighter', + 'light', + 'main', + 'dark', + 'darkest', + 'contrast', +]); + +export function resolveThemingScope(theme: CanvasProviderTheme | undefined): CanvasThemingScope { + if (!theme) { + return 'brand'; + } + if (theme.themeScope) { + return theme.themeScope; + } + + if (isNumericalTheme(theme)) { + return theme.themeScope ?? 'brand'; + } + + const palette = theme.canvas?.palette; + if (!palette) { + return 'brand'; + } + + if (palette.common && Object.keys(palette.common).length > 0) { + const commonOnlyFocus = + Object.keys(palette.common).length === 1 && palette.common.focusOutline != null; + if (!commonOnlyFocus) { + return 'full'; + } + } + + for (const color of ['primary', 'error', 'alert', 'success', 'neutral'] as const) { + const colorPalette = palette[color]; + if (!colorPalette) { + continue; + } + for (const key of Object.keys(colorPalette)) { + if (color === 'primary' && key === 'main') { + continue; + } + if (EXTENDED_RAMP_KEYS.has(key)) { + return 'full'; + } + } + } + + return 'brand'; +} + +export function isPrimaryOnlyInput(theme: CanvasProviderTheme | undefined): boolean { + if (!theme) { + return false; + } + if (isNumericalTheme(theme)) { + if (theme.system || theme.selected) { + return false; + } + const brand = theme.brand; + if (!brand) { + return false; + } + const hasNonPrimary = ['critical', 'caution', 'positive', 'neutral', 'action'].some( + k => brand[k as keyof typeof brand] != null + ); + if (hasNonPrimary) { + return false; + } + const primary = brand.primary; + if (!primary) { + return false; + } + const keys = Object.keys(primary); + return keys.length === 1 && keys[0] === '600'; + } + + const palette = theme.canvas?.palette; + if (!palette?.primary) { + return false; + } + const primaryKeys = Object.keys(palette.primary); + const hasOnlyMain = primaryKeys.length === 1 && palette.primary.main != null; + const hasCommon = palette.common != null && Object.keys(palette.common).length > 0; + const hasOtherColors = ['error', 'alert', 'success', 'neutral'].some( + c => palette[c as keyof typeof palette] != null + ); + return hasOnlyMain && !hasOtherColors && !hasCommon; +} diff --git a/modules/react/common/spec/brandScope.spec.ts b/modules/react/common/spec/brandScope.spec.ts new file mode 100644 index 0000000000..d7fefb18a0 --- /dev/null +++ b/modules/react/common/spec/brandScope.spec.ts @@ -0,0 +1,31 @@ +import {brand, system} from '@workday/canvas-tokens-web'; + +import {applyPrimaryBrandBundle, writeIndependentBrandTokens} from '../lib/theming/brandScope'; + +describe('applyPrimaryBrandBundle', () => { + it('writes button and selected tokens but not focus', () => { + const style: Record = {}; + applyPrimaryBrandBundle('red', style); + + expect(style[brand.action.base as string]).toBe('red'); + expect(style[brand.primary600 as string]).toBe('red'); + expect(style[system.color.brand.accent.primary as string]).toBe('red'); + expect(style[system.color.brand.fg.selected as string]).toBeDefined(); + expect(style[system.color.brand.surface.selected as string]).toBeDefined(); + expect(style[system.color.brand.focus.primary as string]).toBeUndefined(); + expect(style[system.color.brand.border.primary as string]).toBeUndefined(); + }); +}); + +describe('writeIndependentBrandTokens', () => { + it('writes focus when focusOutline is explicitly set', () => { + const style: Record = {}; + writeIndependentBrandTokens( + {canvas: {palette: {primary: {main: 'red'}, common: {focusOutline: 'teal'}}}}, + style + ); + + expect(style[system.color.brand.focus.primary as string]).toBe('teal'); + expect(style[system.color.brand.border.primary as string]).toBe('teal'); + }); +}); diff --git a/modules/react/common/spec/sanaTheme.spec.ts b/modules/react/common/spec/sanaTheme.spec.ts new file mode 100644 index 0000000000..1079404096 --- /dev/null +++ b/modules/react/common/spec/sanaTheme.spec.ts @@ -0,0 +1,22 @@ +import {brand} from '@workday/canvas-tokens-web'; + +import {canvasThemeToCssVars} from '../lib/CanvasProvider'; +import {defaultCanvasTheme} from '../lib/theming'; +import {sanaCanvasNumericalTheme, sanaCanvasProviderTheme} from '../lib/theming/sanaTheme'; + +describe('sanaCanvasNumericalTheme', () => { + it('references Sana brand CSS variables instead of defaultCanvasTheme literals', () => { + expect(sanaCanvasNumericalTheme.brand?.neutral?.['600']).toBe(`var(${brand.neutral600})`); + expect(sanaCanvasNumericalTheme.brand?.action?.base).toBe(`var(${brand.neutral975})`); + expect(sanaCanvasNumericalTheme.brand?.neutral?.['600']).not.toBe( + defaultCanvasTheme.palette.neutral.main + ); + }); + + it('writes brand tokens when passed to canvasThemeToCssVars', () => { + const {style} = canvasThemeToCssVars(sanaCanvasProviderTheme, {}); + expect(Object.keys(style).length).toBeGreaterThan(0); + expect(style[brand.neutral600 as any]).toBe(`var(${brand.neutral600})`); + expect(style[brand.action.base as any]).toBe(`var(${brand.neutral975})`); + }); +}); diff --git a/modules/react/common/spec/theming-types.spec.ts b/modules/react/common/spec/theming-types.spec.ts new file mode 100644 index 0000000000..e30e8b5cad --- /dev/null +++ b/modules/react/common/spec/theming-types.spec.ts @@ -0,0 +1,54 @@ +import {isNumericalTheme, isPrimaryOnlyInput, resolveThemingScope} from '../lib/theming/types'; + +describe('isNumericalTheme', () => { + it('is false for the deprecated shape', () => { + expect(isNumericalTheme({canvas: {palette: {primary: {main: 'red'}}}})).toBe(false); + }); + + it('is true for the numerical shape', () => { + expect(isNumericalTheme({brand: {primary: {'600': 'red'}}})).toBe(true); + }); + + it('is false for undefined and empty', () => { + expect(isNumericalTheme(undefined)).toBe(false); + expect(isNumericalTheme({} as any)).toBe(false); + }); +}); + +describe('resolveThemingScope', () => { + it('defaults to brand for primary-only input', () => { + expect(resolveThemingScope({canvas: {palette: {primary: {main: 'red'}}}})).toBe('brand'); + }); + + it('promotes to full when extended ramp keys are provided', () => { + expect( + resolveThemingScope({canvas: {palette: {primary: {main: 'red', lightest: '#fff'}}}}) + ).toBe('full'); + }); + + it('respects explicit themeScope', () => { + expect( + resolveThemingScope({themeScope: 'full', canvas: {palette: {primary: {main: 'red'}}}}) + ).toBe('full'); + }); + + it('numerical shape defaults to brand even with extended ramp keys', () => { + expect(resolveThemingScope({brand: {critical: {'600': 'red', '700': 'darkred'}}})).toBe( + 'brand' + ); + }); +}); + +describe('isPrimaryOnlyInput', () => { + it('is true for semantic primary.main only', () => { + expect(isPrimaryOnlyInput({canvas: {palette: {primary: {main: 'red'}}}})).toBe(true); + }); + + it('is false when common tokens are set', () => { + expect( + isPrimaryOnlyInput({ + canvas: {palette: {primary: {main: 'red'}, common: {focusOutline: 'teal'}}}, + }) + ).toBe(false); + }); +}); diff --git a/modules/react/common/spec/useCanvasThemeToCssVars.spec.tsx b/modules/react/common/spec/useCanvasThemeToCssVars.spec.tsx new file mode 100644 index 0000000000..2b5274c11c --- /dev/null +++ b/modules/react/common/spec/useCanvasThemeToCssVars.spec.tsx @@ -0,0 +1,37 @@ +import {renderHook} from '@testing-library/react'; + +import {brand, system} from '@workday/canvas-tokens-web'; + +import {useCanvasThemeToCssVars} from '../lib/CanvasProvider'; + +describe('useCanvasThemeToCssVars — brand scope', () => { + it('writes brand numerical CSS variables directly', () => { + const {result} = renderHook(() => + useCanvasThemeToCssVars({brand: {primary: {'600': 'rebeccapurple'}}}, {}) + ); + expect(result.current.style[brand.primary600 as any]).toBe('rebeccapurple'); + expect(result.current.style[brand.action.base as any]).toBe('rebeccapurple'); + }); + + it('does not override focus when only primary is set', () => { + const {result} = renderHook(() => + useCanvasThemeToCssVars({canvas: {palette: {primary: {main: 'red'}}}}, {}) + ); + expect(result.current.style[brand.action.base as any]).toBe('red'); + expect(result.current.style[system.color.brand.focus.primary as any]).toBeUndefined(); + expect( + result.current.style[system.color.brand.surface.primary?.default as any] + ).toBeUndefined(); + }); + + it('writes focus independently from primary', () => { + const {result} = renderHook(() => + useCanvasThemeToCssVars( + {canvas: {palette: {primary: {main: 'red'}, common: {focusOutline: 'teal'}}}}, + {} + ) + ); + expect(result.current.style[brand.action.base as any]).toBe('red'); + expect(result.current.style[system.color.brand.focus.primary as any]).toBe('teal'); + }); +}); diff --git a/modules/react/common/stories/mdx/Theming.mdx b/modules/react/common/stories/mdx/Theming.mdx index e4b4bfaa68..7e51e62aba 100644 --- a/modules/react/common/stories/mdx/Theming.mdx +++ b/modules/react/common/stories/mdx/Theming.mdx @@ -4,6 +4,7 @@ import {ExampleCodeBlock} from '@workday/canvas-kit-docs'; import {RTL} from './examples/RTL'; import {Theming} from './examples/Theming'; +import {ThemingBrandScope} from './examples/ThemingBrandScope'; @@ -138,6 +139,113 @@ application, such as: For all other cases, use global theming at `:root` to ensure consistent theming throughout your application. +## Global Theme Import Order (Sana Canvas) + +When using Sana Canvas, import `@workday/canvas-tokens-web/css/sana/_variables.css` **after** +base, brand, and system in a single root entry point. Sana's `[data-theme="sana-canvas"]` selector +and `:root` rules have equal specificity — source order determines the winner. Importing sana last +is required and sufficient. + +```css +@import '@workday/canvas-tokens-web/css/base/_variables.css'; +@import '@workday/canvas-tokens-web/css/brand/_variables.css'; +@import '@workday/canvas-tokens-web/css/system/_variables.css'; +/* Sana last — wins the cascade tie when data-theme="sana-canvas" is on */ +@import '@workday/canvas-tokens-web/css/sana/_variables.css'; +``` + +Scoped `CanvasProvider` theming is unrelated to this import order. + +## Brand Theme API (`CanvasProvider`) + +The preferred `theme` shape uses a numerical `brand` object. Each key maps 1:1 to a +`--cnvs-brand-*` CSS variable unless noted as a shortcut below. + +### What is themable? + +| You set | Components affected | +| ------- | ------------------- | +| `brand.primary['600']` alone | `PrimaryButton`, selected `Menu.Item` (text + background) | +| `brand.primary['500']` | Focus rings, border primary (independent of `600`) | +| `brand.action.*` | `PrimaryButton` (read before `brand.primary`) | +| `brand.critical.*` | `TextInput` error, critical accents | +| `brand.caution.*` | `TextInput` caution, caution focus | +| `brand.positive.*` | `Checkbox`, `Radio` checked states | +| `brand.neutral.*` | Neutral brand text/surfaces | +| `selected.fg` / `selected.surface` | Selected list/menu states directly | + +**Focus does not follow primary.** Setting only `brand.primary['600']` leaves focus rings at the +default blue unless you also set `brand.primary['500']` or `canvas.palette.common.focusOutline`. + +### Minimal example (brand scope — default) + +```tsx +import {CanvasProvider} from '@workday/canvas-kit-react/common'; +import {base} from '@workday/canvas-tokens-web'; + + + + +``` + + + +### Explicit overrides + +```tsx + + + +``` + +### `themeScope` + +| Value | Numerical `brand` shape | Legacy `canvas.palette` shape | +| ----- | ----------------------- | ------------------------------ | +| `'brand'` (default) | `primary['600']` shortcut → buttons + selected; other keys literal 1:1 | `primary.main` shortcut only | +| `'full'` | All keys literal 1:1, no shortcut | Auto-generated ramps + broad system token forwarding | + +Use `themeScope="full"` on the legacy shape only when you need the old auto-ramp behavior. + +### Sana Canvas preset + +Sana's full visual treatment (fonts, shapes, system surfaces) comes from **global CSS** +(`data-theme="sana-canvas"` + `sana/_variables.css`). For **popup parity** (menus, selects), also +pass the JS preset at your root `CanvasProvider`: + +```tsx +import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; + + + + +``` + +### Visual testing + +Use the **Features/Theming → Sana Canvas** and **Canvas** stories to compare global vs scoped +branding side-by-side. Open **Canvas** with `?theme=canvas` in the URL. + +### Migration: semantic → numerical + +| Old (`canvas.palette`) | New (`brand`) | +| ---------------------- | ------------- | +| `palette.primary.main` | `brand.primary['600']` | +| `palette.primary.dark` (selected text) | `selected.fg` or `brand.primary['700']` | +| `palette.primary.lighter` (selected bg) | `selected.surface` or `brand.primary.A50` | +| `palette.common.focusOutline` | `brand.primary['500']` (independent of `600`) | +| `palette.error.*` | `brand.critical.*` | +| `palette.alert.*` | `brand.caution.*` | +| `palette.success.*` | `brand.positive.*` | +| Full auto-generated ramp | `themeScope="full"` on legacy shape | + ## ✅ Preferred Approach (v14+) Canvas Kit v14 and v15 promote using CSS variables for theming, which can be applied in two ways: diff --git a/modules/react/common/stories/mdx/Theming.stories.tsx b/modules/react/common/stories/mdx/Theming.stories.tsx index 997355cd13..3ca1a78af8 100644 --- a/modules/react/common/stories/mdx/Theming.stories.tsx +++ b/modules/react/common/stories/mdx/Theming.stories.tsx @@ -18,6 +18,11 @@ export const Theming = { render: ThemingExample, }; +export const BrandScope = { + name: 'Brand Scope', + render: ThemingBrandScope, +}; + export const RTL = { render: RTLExample, }; diff --git a/modules/react/common/stories/mdx/examples/ThemingBrandScope.tsx b/modules/react/common/stories/mdx/examples/ThemingBrandScope.tsx new file mode 100644 index 0000000000..5401a38ecc --- /dev/null +++ b/modules/react/common/stories/mdx/examples/ThemingBrandScope.tsx @@ -0,0 +1,27 @@ +import {PrimaryButton} from '@workday/canvas-kit-react/button'; +import {Card} from '@workday/canvas-kit-react/card'; +import {CanvasProvider} from '@workday/canvas-kit-react/common'; +import {Menu} from '@workday/canvas-kit-react/menu'; +import {base} from '@workday/canvas-tokens-web'; + +export const ThemingBrandScope = () => ( + + + Brand scope + +

+ Only primary is set — buttons and selected menu items update. Focus rings stay default. +

+ Primary + + + + Other item + Selected item + + + +
+
+
+); diff --git a/modules/react/common/stories/theming/ThemeComparison.stories.tsx b/modules/react/common/stories/theming/ThemeComparison.stories.tsx new file mode 100644 index 0000000000..f0cd453f09 --- /dev/null +++ b/modules/react/common/stories/theming/ThemeComparison.stories.tsx @@ -0,0 +1,56 @@ +import {Meta} from '@storybook/react'; + +import {sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; +import {Flex} from '@workday/canvas-kit-react/layout'; +import {createStyles} from '@workday/canvas-kit-styling'; +import {system} from '@workday/canvas-tokens-web'; + +import {brandScopePrimaryOnly, primaryWithFocus} from '../../../../../utils/storybook/customThemes'; +import {BrandingFixture} from './examples/BrandingFixture'; + +const rowStyles = createStyles({ + gap: system.gap.xl, + flexWrap: 'wrap', +}); + +export default { + title: 'Features/Theming', +} as Meta; + +const comparisonRender = () => ( + + + + + + +); + +export const SanaCanvas = { + name: 'Sana Canvas', + parameters: { + docs: { + description: { + story: + 'Default global Sana theme (`data-theme="sana-canvas"`). Compare with Canvas using `?theme=canvas` in the URL.', + }, + }, + chromatic: {disable: false}, + }, + render: comparisonRender, +}; + +export const Canvas = { + name: 'Canvas', + parameters: { + docs: { + description: { + story: 'Open with `?theme=canvas` to set `data-theme="canvas"` on ``.', + }, + }, + }, + render: comparisonRender, +}; diff --git a/modules/react/common/stories/theming/examples/BrandingFixture.tsx b/modules/react/common/stories/theming/examples/BrandingFixture.tsx new file mode 100644 index 0000000000..89c5a12966 --- /dev/null +++ b/modules/react/common/stories/theming/examples/BrandingFixture.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; + +import {PrimaryButton} from '@workday/canvas-kit-react/button'; +import {CanvasProvider, CanvasProviderTheme} from '@workday/canvas-kit-react/common'; +import {FormField} from '@workday/canvas-kit-react/form-field'; +import {Flex} from '@workday/canvas-kit-react/layout'; +import {Menu} from '@workday/canvas-kit-react/menu'; +import {TextInput} from '@workday/canvas-kit-react/text-input'; +import {createStyles} from '@workday/canvas-kit-styling'; +import {system} from '@workday/canvas-tokens-web'; + +export type BrandingFixtureProps = { + label?: string; + scopedTheme?: CanvasProviderTheme; +}; + +const columnStyles = createStyles({ + flexDirection: 'column', + gap: system.gap.md, + padding: system.padding.md, + minWidth: '280px', +}); + +const SelectedMenu = () => ( + + + + Normal item + Selected item + + + +); + +const FixtureContent = () => ( + <> + Primary button + + + Focus sample + + + + + +); + +export const BrandingFixture = ({label, scopedTheme}: BrandingFixtureProps) => { + const content = ; + + return ( + + {label ? {label} : null} + {scopedTheme ? {content} : content} + + ); +}; diff --git a/modules/react/popup/lib/hooks/usePopupStack.ts b/modules/react/popup/lib/hooks/usePopupStack.ts index 19c477dc99..1e6e64f89e 100644 --- a/modules/react/popup/lib/hooks/usePopupStack.ts +++ b/modules/react/popup/lib/hooks/usePopupStack.ts @@ -2,7 +2,12 @@ import {Theme, ThemeContext} from '@emotion/react'; import React from 'react'; import {PopupStack} from '@workday/canvas-kit-popup-stack'; -import {isElementRTL, useCanvasThemeToCssVars, useLocalRef} from '@workday/canvas-kit-react/common'; +import { + CanvasProviderTheme, + canvasThemeToCssVars, + isElementRTL, + useLocalRef, +} from '@workday/canvas-kit-react/common'; /** * **Note:** If you're using {@link Popper}, you do not need to use this hook directly. @@ -53,7 +58,7 @@ export const usePopupStack = ( const {elementRef, localRef} = useLocalRef(ref); const theme = React.useContext(ThemeContext as React.Context); - const {style} = useCanvasThemeToCssVars(theme, {}); + const {style} = canvasThemeToCssVars(theme as CanvasProviderTheme, {}); const firstLoadRef = React.useRef(true); // React 19 can call a useState more than once, so we need to track if we've already created a container // useState function input ensures we only create a container once. diff --git a/utils/storybook/CanvasProviderDecorator.tsx b/utils/storybook/CanvasProviderDecorator.tsx index 53992de776..de371e62b3 100644 --- a/utils/storybook/CanvasProviderDecorator.tsx +++ b/utils/storybook/CanvasProviderDecorator.tsx @@ -1,11 +1,7 @@ import {makeDecorator} from '@storybook/preview-api'; import * as React from 'react'; -import { - CanvasProvider, - PartialEmotionCanvasTheme, - defaultCanvasTheme, -} from '@workday/canvas-kit-react/common'; +import {CanvasProvider, PartialCanvasTheme} from '@workday/canvas-kit-react/common'; import {createStyles} from '@workday/canvas-kit-styling'; import {system} from '@workday/canvas-tokens-web'; @@ -16,14 +12,12 @@ const storyStyles = createStyles({ export default makeDecorator({ name: 'canvasProviderDecorator', parameterName: 'canvasProviderDecorator', - wrapper: (storyFn, context, {parameters = {}}) => { - const theme: PartialEmotionCanvasTheme = { - canvas: parameters.theme || defaultCanvasTheme, - }; - return ( - - {storyFn(context) as React.ReactNode} - - ); - }, + wrapper: (storyFn, context, {parameters = {}}) => ( + + {storyFn(context) as React.ReactNode} + + ), }); diff --git a/utils/storybook/customThemes.ts b/utils/storybook/customThemes.ts index 5b64b95a91..15600ad919 100644 --- a/utils/storybook/customThemes.ts +++ b/utils/storybook/customThemes.ts @@ -1,4 +1,5 @@ -import {PartialCanvasTheme} from '@workday/canvas-kit-react/common'; +import {CanvasNumericalBrandTheme, PartialCanvasTheme} from '@workday/canvas-kit-react/common'; +import {base} from '@workday/canvas-tokens-web'; export const customColorTheme: PartialCanvasTheme = { palette: { @@ -23,3 +24,18 @@ export const customColorTheme: PartialCanvasTheme = { }, }, }; + +/** Brand-scope preset: primary only → buttons + selected states */ +export const brandScopePrimaryOnly: CanvasNumericalBrandTheme = { + brand: {primary: {'600': base.magenta600}}, +}; + +/** Primary + independent focus color */ +export const primaryWithFocus = { + canvas: { + palette: { + primary: {main: base.magenta600}, + common: {focusOutline: base.teal500}, + }, + }, +}; From 258ac7536b9e3cfc5dd4230e54f0dd74d50d52aa Mon Sep 17 00:00:00 2001 From: "manuel.carrera" Date: Mon, 20 Jul 2026 10:49:02 -0600 Subject: [PATCH 03/19] fix: Update canvas provider theming to support branch --- modules/react/common/lib/CanvasProvider.tsx | 35 +-- .../spec/useCanvasThemeToCssVars.spec.tsx | 9 +- .../common/stories/mdx/Theming.stories.tsx | 5 - .../theming/ThemeComparison.stories.tsx | 20 +- .../theming/examples/BrandingFixture.tsx | 225 +++++++++++++++--- modules/react/testing/lib/StaticStates.tsx | 14 +- 6 files changed, 227 insertions(+), 81 deletions(-) diff --git a/modules/react/common/lib/CanvasProvider.tsx b/modules/react/common/lib/CanvasProvider.tsx index 885a828fbf..f32e6388b8 100644 --- a/modules/react/common/lib/CanvasProvider.tsx +++ b/modules/react/common/lib/CanvasProvider.tsx @@ -183,31 +183,34 @@ export const useCanvasThemeToCssVars = ( export const CanvasProvider = ({ children, - theme = {canvas: {}}, + theme, themeScope, ...props }: CanvasProviderProps & React.HTMLAttributes) => { const {className, ...elemProps} = useCanvasThemeToCssVars(theme, props, themeScope); const cache = getCache(); const rest = {...elemProps, ...props}; - const emotionTheme = isNumericalTheme(theme) - ? ({canvas: defaultCanvasTheme} as Theme) - : (theme as Theme); + const emotionTheme = theme + ? isNumericalTheme(theme) + ? ({canvas: defaultCanvasTheme} as Theme) + : (theme as Theme) + : undefined; + const content = ( +
)} + > + {children} +
+ ); return ( - -
)} - > - {children} -
-
+ {emotionTheme ? {content} : content}
); }; diff --git a/modules/react/common/spec/useCanvasThemeToCssVars.spec.tsx b/modules/react/common/spec/useCanvasThemeToCssVars.spec.tsx index 2b5274c11c..6cbe0edcaa 100644 --- a/modules/react/common/spec/useCanvasThemeToCssVars.spec.tsx +++ b/modules/react/common/spec/useCanvasThemeToCssVars.spec.tsx @@ -2,7 +2,14 @@ import {renderHook} from '@testing-library/react'; import {brand, system} from '@workday/canvas-tokens-web'; -import {useCanvasThemeToCssVars} from '../lib/CanvasProvider'; +import {canvasThemeToCssVars, useCanvasThemeToCssVars} from '../lib/CanvasProvider'; + +describe('canvasThemeToCssVars', () => { + it('does not write CSS variables when theme is undefined', () => { + const {style} = canvasThemeToCssVars(undefined, {}); + expect(Object.keys(style)).toHaveLength(0); + }); +}); describe('useCanvasThemeToCssVars — brand scope', () => { it('writes brand numerical CSS variables directly', () => { diff --git a/modules/react/common/stories/mdx/Theming.stories.tsx b/modules/react/common/stories/mdx/Theming.stories.tsx index 3ca1a78af8..997355cd13 100644 --- a/modules/react/common/stories/mdx/Theming.stories.tsx +++ b/modules/react/common/stories/mdx/Theming.stories.tsx @@ -18,11 +18,6 @@ export const Theming = { render: ThemingExample, }; -export const BrandScope = { - name: 'Brand Scope', - render: ThemingBrandScope, -}; - export const RTL = { render: RTLExample, }; diff --git a/modules/react/common/stories/theming/ThemeComparison.stories.tsx b/modules/react/common/stories/theming/ThemeComparison.stories.tsx index f0cd453f09..88bc8f5366 100644 --- a/modules/react/common/stories/theming/ThemeComparison.stories.tsx +++ b/modules/react/common/stories/theming/ThemeComparison.stories.tsx @@ -9,8 +9,8 @@ import {brandScopePrimaryOnly, primaryWithFocus} from '../../../../../utils/stor import {BrandingFixture} from './examples/BrandingFixture'; const rowStyles = createStyles({ - gap: system.gap.xl, - flexWrap: 'wrap', + // gap: system.gap.xl, + // flexWrap: 'wrap', }); export default { @@ -20,12 +20,12 @@ export default { const comparisonRender = () => ( - - + */} ); @@ -42,15 +42,3 @@ export const SanaCanvas = { }, render: comparisonRender, }; - -export const Canvas = { - name: 'Canvas', - parameters: { - docs: { - description: { - story: 'Open with `?theme=canvas` to set `data-theme="canvas"` on ``.', - }, - }, - }, - render: comparisonRender, -}; diff --git a/modules/react/common/stories/theming/examples/BrandingFixture.tsx b/modules/react/common/stories/theming/examples/BrandingFixture.tsx index 89c5a12966..99ea27c7ad 100644 --- a/modules/react/common/stories/theming/examples/BrandingFixture.tsx +++ b/modules/react/common/stories/theming/examples/BrandingFixture.tsx @@ -1,12 +1,39 @@ import * as React from 'react'; -import {PrimaryButton} from '@workday/canvas-kit-react/button'; +import {KBD} from '@workday/canvas-kit-labs-react/kbd'; +import {MultiSelect} from '@workday/canvas-kit-preview-react/multi-select'; +import {RadioGroup} from '@workday/canvas-kit-preview-react/radio'; +import {StatusIndicator} from '@workday/canvas-kit-preview-react/status-indicator'; +import {Switch} from '@workday/canvas-kit-preview-react/switch'; +import {ActionBar} from '@workday/canvas-kit-react/action-bar'; +import {Avatar} from '@workday/canvas-kit-react/avatar'; +import {CountBadge} from '@workday/canvas-kit-react/badge'; +import {Banner} from '@workday/canvas-kit-react/banner'; +import {Breadcrumbs} from '@workday/canvas-kit-react/breadcrumbs'; +import { + DeleteButton, + PrimaryButton, + SecondaryButton, + TertiaryButton, +} from '@workday/canvas-kit-react/button'; +import {Card} from '@workday/canvas-kit-react/card'; +import {Checkbox} from '@workday/canvas-kit-react/checkbox'; import {CanvasProvider, CanvasProviderTheme} from '@workday/canvas-kit-react/common'; -import {FormField} from '@workday/canvas-kit-react/form-field'; +import {Expandable} from '@workday/canvas-kit-react/expandable'; +import {FormField, FormFieldGroup} from '@workday/canvas-kit-react/form-field'; +import {InformationHighlight} from '@workday/canvas-kit-react/information-highlight'; import {Flex} from '@workday/canvas-kit-react/layout'; -import {Menu} from '@workday/canvas-kit-react/menu'; +import {LoadingDots} from '@workday/canvas-kit-react/loading-dots'; +import {Menu, MenuCard} from '@workday/canvas-kit-react/menu'; +import {Pill} from '@workday/canvas-kit-react/pill'; +import {SegmentedControl} from '@workday/canvas-kit-react/segmented-control'; +import {Select} from '@workday/canvas-kit-react/select'; +import {SidePanel} from '@workday/canvas-kit-react/side-panel'; +import {TextArea} from '@workday/canvas-kit-react/text-area'; import {TextInput} from '@workday/canvas-kit-react/text-input'; +import {Tooltip} from '@workday/canvas-kit-react/tooltip'; import {createStyles} from '@workday/canvas-kit-styling'; +import {gridIcon, listDetailIcon, listViewIcon} from '@workday/canvas-system-icons-web'; import {system} from '@workday/canvas-tokens-web'; export type BrandingFixtureProps = { @@ -15,43 +42,171 @@ export type BrandingFixtureProps = { }; const columnStyles = createStyles({ - flexDirection: 'column', - gap: system.gap.md, - padding: system.padding.md, - minWidth: '280px', + backgroundColor: system.color.bg.alt.default, + width: '100vw', }); -const SelectedMenu = () => ( - - - - Normal item - Selected item - - - -); - -const FixtureContent = () => ( - <> - Primary button - - - Focus sample - - - - - -); - export const BrandingFixture = ({label, scopedTheme}: BrandingFixtureProps) => { - const content = ; - return ( - {label ? {label} : null} - {scopedTheme ? {content} : content} + + {label} + + {/* */} + {/* */} + Item 1 + Item 2 + Item 3 + {/* */} + {/* */} + + + + + Primary button + Secondary button + Tertiary button + Delete button + + + + + + Table + + + List + + + Detail + + + + + + Item 1 + Item 2 + + + + + + Card heading + Card content + + + Card heading + Card content + + + + + + Title + + + Content + + + + + + + Pill Label + + + + + Status Indicator + + + + + + 3 Alerts + + + + Information Highlight + + + + + Form Field + + + + + + Form Field + + + + + + Form Field + + + + + + Form Field + + + + + + Form Field + + + + + + Form Field + + Choose Your Pizza Crust + + + + Deep dish + + + Thin + + + Gluten free + + + Cauliflower + + + Butter - the best thing to put on bread + + + + + + + ); }; diff --git a/modules/react/testing/lib/StaticStates.tsx b/modules/react/testing/lib/StaticStates.tsx index c0d4bad604..11cb1a5121 100644 --- a/modules/react/testing/lib/StaticStates.tsx +++ b/modules/react/testing/lib/StaticStates.tsx @@ -1,3 +1,4 @@ +import {ThemeProvider} from '@emotion/react'; import * as React from 'react'; import { @@ -43,13 +44,10 @@ export const StaticStates: React.FC< localTheme._styleRewriteFn = convertToStaticStates; return ( - - {children} - + + + {children} + + ); }; From 9a8f7e5f29a30a4defda3948b69905a3939a507a Mon Sep 17 00:00:00 2001 From: "manuel.carrera" Date: Tue, 28 Jul 2026 13:39:23 -0600 Subject: [PATCH 04/19] fix: Update theming docs and upgrade guide --- README.md | 33 +- modules/docs/llm/theming.md | 656 ++---------------- .../llm/upgrade-guides/16.0-UPGRADE-GUIDE.md | 75 +- modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx | 77 +- modules/react/common/lib/CanvasProvider.tsx | 2 +- modules/react/common/lib/theming/README.md | 36 +- modules/react/common/lib/theming/index.ts | 1 - modules/react/common/lib/theming/types.ts | 39 -- .../react/common/spec/theming-types.spec.ts | 16 +- modules/react/common/stories/mdx/Theming.mdx | 591 ++-------------- .../mdx/examples/ThemingBrandScope.tsx | 11 +- .../theming/ThemeComparison.stories.tsx | 8 +- .../theming/examples/BrandingFixture.tsx | 12 +- modules/react/testing/lib/StaticStates.tsx | 2 +- 14 files changed, 318 insertions(+), 1241 deletions(-) diff --git a/README.md b/README.md index fd975adaed..e8ac951d3d 100644 --- a/README.md +++ b/README.md @@ -45,48 +45,33 @@ or npm install @workday/canvas-kit-react @workday/canvas-tokens-web ``` -> **Note:** If your application does not already provide `Roboto` as a font, you can install -> `@workday/canvas-kit-react-fonts`. The example below shows how to inject the fonts, but you can -> omit this if you're already loading fonts. - **Usage** -To ensure fonts are loaded correctly, update your root `index.js` file. +Update your root `index.js` file to import CSS variables and render your app. ```jsx import {createRoot} from 'react-dom/client'; -import {injectGlobal} from '@emotion/css'; -import {fonts} from '@workday/canvas-kit-react-fonts'; -import {system} from '@workday/canvas-tokens-web'; -import {cssVar} from '@workday/canvas-kit-styling'; import '@workday/canvas-tokens-web/css/base/_variables.css'; import '@workday/canvas-tokens-web/css/brand/_variables.css'; import '@workday/canvas-tokens-web/css/component/_variables.css'; import '@workday/canvas-tokens-web/css/system/_variables.css'; - +import '@workday/canvas-tokens-web/css/sana/_variables.css'; import {App} from './App'; -injectGlobal({ - ...fonts, - 'html, body': { - fontFamily: cssVar(system.fontFamily.default), - margin: 0, - minHeight: '100vh', - }, - '#root, #root < div': { - minHeight: '100vh', - ...system.type.body.sm, - }, -}); - const container = document.getElementById('root')!; const root = createRoot(container); root.render(); ``` -The in your `App.js` you can set a global theme. +Set `data-theme="sana-canvas"` on `` in your `index.html`: + +```html + +``` + +Then in your `App.js` you can wrap your application with `CanvasProvider`. ```jsx import {CanvasProvider} from '@workday/canvas-kit-react/common'; diff --git a/modules/docs/llm/theming.md b/modules/docs/llm/theming.md index dc1068cb77..20a67c9b72 100644 --- a/modules/docs/llm/theming.md +++ b/modules/docs/llm/theming.md @@ -3,654 +3,114 @@ source_file: react/common/stories/mdx/Theming.mdx live_url: https://workday.github.io/canvas-kit/react/common/stories/mdx/Theming --- - - # Canvas Kit Theming Guide -## Overview - -Canvas Kit v14 introduces a significant shift in our approach to theming: we've moved away from -JavaScript-based theme objects to CSS variables. This change provides better performance, improved -developer experience, and greater flexibility for theming applications. - -> **📌 Quick Start:** -> -> 1. **Import CSS variables once** at the root level of your application (e.g., in `index.css`) -> 2. **Override tokens at `:root`** for global theming — this is the recommended approach -> 3. **Use `CanvasProvider` scoped theming only** for specific scenarios like multi-brand sections -> or embedded components -> -> If your application renders within an environment that already imports these CSS variables, \*\*do -> not re- - -```tsx - - {' '} - {' '} - -``` - -This would use `chroma.js` to generate a palette based on the `main` color provided. - -**Why we're moving away from this approach:** - -- Performance overhead from JavaScript theme object processing -- Limited flexibility for complex theming scenarios -- Inconsistent cascade behavior - -Any time `theme` is passed, the `CanvasProvider` would generate a palette and attach brand variables -via a `className` scoping those brand variables to a wrapping div. In order for us to provide a -better solution to theming that is scalable and is more aligned with our CSS variables, we changed -this approach. - -**Note:** While we support theme overrides, we advise to use global theming via CSS Variables. - -## What is a Cascade Barrier? - -When we say "cascade barrier", we're talking about how -[CSS cascades](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascade/Cascade) and takes -precedence. Take the following example: - -```css -:root { - --cnbvs-brand-primary-base: blue; -} - -// the element with the class .my-app will have a higher specificity than root, creating a barrier where the CSS variables gets redefined and takes precedence over what is defined at root. -.my-app { - --cnvs-brand-primary-base: red; -} -``` - -In the case of the `CanvasProvider` prior to v14, all our brand tokens where defined within a class -and scoped to the `div` that the `CanvasProvider` created. This meant that anything set on `:root` -or outside of the `CanvasProvider` would not be able to cascade down to the components within the -`CanvasProvider`. - -If you provide a `theme` to the `CanvasProvider`, it will create a scoped theme. Note that in v14, -global CSS variables are the recommended way to theme Popups and Modals consistently. - -## Global vs Scoped Theming - -Canvas Kit v14 supports two theming strategies: **global theming** and **scoped theming**. -Understanding the difference is important to avoid unexpected behavior. - -### Global Theming - -Global theming applies CSS variables at the `:root` level, making them available throughout your -entire application. This is the **recommended approach** for most use cases. +Canvas Kit v16 components are Sana-aligned out of the box. The Sana Canvas **theme** is a separate, +opt-in step that updates brand colors, neutrals, surfaces, and shapes at the application +level. -```css -@import '@workday/canvas-tokens-web/css/base/_variables.css'; -:root { - // This is showing how you can change the value of a token at the root level of your application. - --cnvs-brand-primary-base: var(--cnvs-base-palette-magenta-600); -} -``` - -### Scoped Theming - -Scoped theming applies CSS variables to a specific section of your application using the -`CanvasProvider` with either a `className` or `theme` prop. The theme only affects components within -that provider. - -```tsx -// Using the theme prop for scoped theming. This will set the [brand.primary.**] tokens to shades of purple. - - - -``` - -> **⚠️ Warning:** Scoped theming creates a cascade barrier that **will break global theming**. Any -> CSS variables defined at `:root` will be overridden by the scoped theme. Only the tokens -> explicitly defined in the `theme` prop will be changed - other tokens will use their default -> values, not your global overrides. - -### When to Use Scoped Theming +For a full list of what changes when you opt in, see the +[v16 Upgrade Guide](https://workday.github.io/canvas-kit/?path=/docs/guides-upgrade-guides-v-16-0-overview--docs#sana-canvas-theme). -Only use scoped theming when you intentionally need a different theme for a specific section of your -application, such as: +## Sana Canvas Theme -- Embedding a Canvas Kit component in a third-party application with a different brand -- Creating a preview panel that shows components with different themes -- Supporting multi-tenant applications where sections have different branding - -For all other cases, use global theming at `:root` to ensure consistent theming throughout your -application. - -## ✅ Preferred Approach (v14+) - -Canvas Kit v14 promotes using CSS variables for theming, which can be applied in two ways: - -### Method 1: Global CSS Variables (Recommended) - -Apply theming at the global level by importing CSS variable files and overriding values in your root -CSS: +Import the Sana variables **last** in your root CSS and set `data-theme="sana-canvas"` on ``. ```css -/* index.css */ +/* index.css — order matters */ @import '@workday/canvas-tokens-web/css/base/_variables.css'; -@import '@workday/canvas-tokens-web/css/system/_variables.css'; @import '@workday/canvas-tokens-web/css/brand/_variables.css'; @import '@workday/canvas-tokens-web/css/component/_variables.css'; +@import '@workday/canvas-tokens-web/css/system/_variables.css'; +@import '@workday/canvas-tokens-web/css/sana/_variables.css'; :root { - /* Override brand primary colors */ - --cnvs-brand-primary-base: var(--cnvs-base-palette-magenta-600); - --cnvs-brand-primary-light: var(--cnvs-base-palette-magenta-200); - --cnvs-brand-primary-lighter: var(--cnvs-base-palette-magenta-50); - --cnvs-brand-primary-lightest: var(--cnvs-base-palette-magenta-25); - --cnvs-brand-primary-dark: var(--cnvs-base-palette-magenta-700); - --cnvs-brand-primary-darkest: var(--cnvs-base-palette-magenta-800); - --cnvs-brand-primary-accent: var(--cnvs-base-palette-neutral-0); -} -``` - -> **Note:** You should only - -// You can import the CSS variables in a ts file or an index.css file. You do not need to do both. -import '@workday/canvas-tokens-web/css/base/\_variables.css'; import -'@workday/canvas-tokens-web/css/system/\_variables.css'; import -'@workday/canvas-tokens-web/css/brand/\_variables.css'; import -'@workday/canvas-tokens-web/css/component/\_variables.css'; - -// Generate a class name that defines CSS variables const themedBrand = createStyles({ -[brand.primary.accent]: base.neutral0, [brand.primary.darkest]: base.blue800, [brand.primary.dark]: -base.blue700, [brand.primary.base]: base.blue600, [brand.primary.light]: base.blue200, -[brand.primary.lighter]: base.blue50, [brand.primary.lightest]: base.blue25, }) - - - - -``` - -### Theming Modals and Dialogs - -Previously, the `usePopupStack` hook created a CSS class name that was passed to our Popups. We -attached those theme styles to that class name. This allowed the theme to be available in our -Popups. But it also created a cascade barrier that blocked the global theme from being applied to -our Popup components. Because we now use global CSS variables, we no longer need this class name to -provide the global theme to Popups. But we have to remove this generated class name to allow the -global theme to be applied to Popups. - -**Before in v13** - -```tsx -// When passing a theme to the Canvas Provider, the `usePopupStack` would grab the theme and generate a class to forward the theme to Modals and Dialogs. This would create a cascade barrier for any CSS variables defined at the root. - - //... rest of modal code - -``` - -**After in v14** - -```tsx -// If you wish to still theme you application and Modals, you can either define the CSS variables at the root level of your application or define a className and pass it to the CanvasProvider. -:root { - --cnvs-brand-primary-base: blue; + /* Optional — override only if you have a custom brand color */ + --cnvs-brand-primary-600: var(--cnvs-base-palette-magenta-600); } - - - //... rest of modal code - -``` - -## CSS Token Structure - -Canvas Kit provides three layers of CSS variables. - -### Base Tokens (`base/_variables.css`) - -Base tokens define foundation palette and design values. - -```css ---cnvs-base-palette-blue-600: oklch(0.5198 0.1782 256.11 / 1); ---cnvs-base-palette-magenta-600: oklch(0.534 0.183 344.19 / 1); ---cnvs-base-font-size-100: 1rem; ---cnvs-base-space-x4: calc(var(--cnvs-base-unit) * 4); -``` - -### Brand Tokens (`brand/_variables.css`) - -Brand tokens define semantic color assignments. - -```css ---cnvs-brand-primary-base: var(--cnvs-base-palette-blue-600); ---cnvs-brand-primary-accent: var(--cnvs-base-palette-neutral-0); ---cnvs-brand-error-base: var(--cnvs-base-palette-red-600); ---cnvs-brand-success-base: var(--cnvs-base-palette-green-600); ``` -### System Tokens (`system/_variables.css`) - -System tokens define component-specific values. - -```css ---cnvs-sys-color-bg-primary-default: var(--cnvs-base-palette-blue-600); ---cnvs-sys-color-text-primary-default: var(--cnvs-base-palette-blue-600); ---cnvs-sys-space-x4: calc(var(--cnvs-base-unit) * 4); +```html + ``` -## Practical Examples +## Classic Canvas (without Sana theme) -### Complete Brand Theming - -```css -/* themes/magenta-theme.css */ -@import '@workday/canvas-tokens-web/css/base/_variables.css'; -@import '@workday/canvas-tokens-web/css/system/_variables.css'; -@import '@workday/canvas-tokens-web/css/brand/_variables.css'; -@import '@workday/canvas-tokens-web/css/component/_variables.css'; +If you are not opting into the Sana Canvas theme, omit `data-theme` from ``. The `:root` +tokens apply as-is — no `theme` prop on `CanvasProvider` is required. -:root { - /* Primary brand colors */ - --cnvs-brand-primary-base: var(--cnvs-base-palette-magenta-600); - --cnvs-brand-primary-light: var(--cnvs-base-palette-magenta-200); - --cnvs-brand-primary-lighter: var(--cnvs-base-palette-magenta-50); - --cnvs-brand-primary-lightest: var(--cnvs-base-palette-magenta-25); - --cnvs-brand-primary-dark: var(--cnvs-base-palette-magenta-700); - --cnvs-brand-primary-darkest: var(--cnvs-base-palette-magenta-800); - --cnvs-brand-primary-accent: var(--cnvs-base-palette-neutral-0); -} +```html + ``` ```tsx -import {PrimaryButton} from '@workday/canvas-kit-react/button'; -import {Card} from '@workday/canvas-kit-react/card'; import {CanvasProvider} from '@workday/canvas-kit-react/common'; -import {createStyles} from '@workday/canvas-kit-styling'; -import {base, brand, system} from '@workday/canvas-tokens-web'; - -const customTheme = createStyles({ - [brand.primary.base]: base.green600, - [brand.primary.dark]: base.green700, - [brand.primary.darkest]: base.green800, - [brand.common.focusOutline]: base.green600, - [system.color.fg.strong]: base.indigo900, - [system.color.border.container]: base.indigo300, -}); - -const App = () => { - return ( - - - Theming - - Theming - - - - - ); -}; -export const Theming = () => { - return ( - - - - ); -}; -``` - -### Dark Mode Implementation - -```css -/* Dark mode theming */ -[data-theme='dark'] { - --cnvs-sys-color-bg-default: var(--cnvs-base-palette-neutral-950); - --cnvs-sys-color-text-default: var(--cnvs-base-palette-neutral-50); - --cnvs-sys-color-border-container: var(--cnvs-base-palette-slate-700); - --cnvs-sys-color-bg-alt-default: var(--cnvs-base-palette-slate-800); -} -``` - -### RTL Support - -Canvas Kit supports RTL out of the box. Our components are styled to use -[CSS logical properties](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_logical_properties_and_values). -If you want to add additional styles based on RTL, you can also use the `:dir` -[pseudo selector](https://developer.mozilla.org/en-US/docs/Web/CSS/:dir). - -#### Setting RTL Direction - -Use the native HTML `dir` attribute to set the text direction. The `CanvasProvider` accepts a `dir` -prop which sets this attribute on its wrapper element: - -```tsx -// Set RTL direction - + ``` -You can also set it on any HTML element: +The sana stylesheet only defines `[data-theme="sana-canvas"]` overrides. Without that attribute, +those rules do not apply. -```tsx -
- -
-``` - -> **Note:** The `dir` attribute is the standard HTML way to set text direction. It's preferred over -> the deprecated `theme.canvas.direction` approach because it works natively with CSS logical -> properties and the `:dir()` pseudo-class. - -#### Using CSS Logical Properties - -CSS logical properties automatically adapt to the text direction. Use these instead of physical -properties: - -```css -/* Physical properties (don't adapt to RTL) */ -.my-component { - margin-left: 1rem; - padding-right: 1rem; - border-left: 1px solid; -} - -/* Logical properties (adapt to RTL automatically) */ -.my-component { - margin-inline-start: 1rem; - padding-inline-end: 1rem; - border-inline-start: 1px solid; -} -``` - -#### Conditional RTL Styles with `:dir()` - -For styles that need to change based on direction (like rotating icons), use the `:dir()` -pseudo-class: +If your application **has** opted into Sana globally but one subsection needs classic Canvas branding, +use `defaultBranding` on a scoped `CanvasProvider`: ```tsx -const rtlButtonStyles = createStyles({ - ':dir(rtl)': { - svg: { - transform: 'rotate(180deg)', - }, - }, -}); -``` +import {CanvasProvider, defaultBranding} from '@workday/canvas-kit-react/common'; -```tsx -import React from 'react'; - -import {PrimaryButton} from '@workday/canvas-kit-react/button'; -import {Card} from '@workday/canvas-kit-react/card'; -import {CanvasProvider} from '@workday/canvas-kit-react/common'; -import {FormField} from '@workday/canvas-kit-react/form-field'; -import {TextInput} from '@workday/canvas-kit-react/text-input'; -import {createStyles, px2rem} from '@workday/canvas-kit-styling'; -import {arrowRightSmallIcon} from '@workday/canvas-system-icons-web'; -import {system} from '@workday/canvas-tokens-web'; - -const rtlStyles = createStyles({ - paddingInlineStart: px2rem(64), -}); - -const rtlButtonStyles = createStyles({ - ':dir(rtl)': { - svg: { - transform: 'rotate(180deg)', - }, - }, -}); - -const App = () => { - const [value, setValue] = React.useState(''); - - const handleChange = (event: React.ChangeEvent) => { - setValue(event.target.value); - }; - return ( - - RTL Support - - - Email - - - - - - RTL - - - - ); -}; - -export const RTL = () => { - return ( - - - - ); -}; -``` - -### Resetting to Default Brand Theme - -If you need to reset the theme in parts of your application, there's a few ways to do this. We -export a `defaultBranding` class that can be applied to the `CanvasProvider` which can wrap parts of -your application. - -```tsx - + ``` -> **Note:** Doing the following **will create a cascade barrier**. Only use this method if you -> intentionally want to override the default theme. +## Scoped Theming -## Migration Guide +Most application teams should use the Sana Canvas theme globally and not pass a `theme` prop to +`CanvasProvider`. Use scoped theming only when a section of your app needs a different brand — for +example, embedding Canvas in a third-party application, multi-tenant branding, or popup parity. -### Step 1: Identify Current Theme Usage +The `theme` prop accepts a numerical `brand` object. Each key maps 1:1 to a `--cnvs-brand-*` CSS +variable. -Find all instances of `CanvasProvider` with theme props in your application. +| You set | Components affected | +| ------- | ------------------- | +| `brand.primary['600']` alone | `PrimaryButton`, selected `Menu.Item` (text + background) | +| `brand.primary['500']` | Focus rings, border primary (independent of `600`) | +| `brand.action.*` | `PrimaryButton` (read before `brand.primary`) | +| `brand.critical.*` | `TextInput` error, critical accents | +| `brand.caution.*` | `TextInput` caution, caution focus | +| `brand.positive.*` | `Checkbox`, `Radio` checked states | +| `brand.neutral.*` | Neutral brand text/surfaces | +| `selected.fg` / `selected.surface` | Selected list/menu states directly | -```tsx -// Find these patterns: - -``` - -### Step 2: Extract Theme Values - -Convert JavaScript theme objects to CSS variable overrides. - -```tsx -// Old approach: -const theme = { - canvas: { - palette: { - primary: { - main: colors.green400, - dark: colors.green500, - } - } - } -}; - -// New approach - CSS variables: -:root { - --cnvs-brand-primary-base: var(--cnvs-base-palette-green-400); - --cnvs-brand-primary-dark: var(--cnvs-base-palette-green-500); -} -``` - -### Step 3: App Level Theming Usage - -Replace theme-based `CanvasProvider` usage with CSS class-based theming. +**Focus does not follow primary.** Setting only `brand.primary['600']` leaves focus rings at the +default blue unless you also set `brand.primary['500']`. ```tsx -// Before: - - - +import {CanvasProvider} from '@workday/canvas-kit-react/common'; +import {base} from '@workday/canvas-tokens-web'; -// After: - - + + ``` -> **Note:** Using a class means you will need to define each property of the palette for full -> control over theming. - -### Step 4: Test Component Rendering - -Verify that Canvas Kit components (like `PrimaryButton`) correctly use the new CSS variables. - -```tsx -// This should automatically use your CSS variable overrides -Themed Button -``` - -## Best Practices - -### 1. Use Semantic Token Names - -Use brand tokens instead of base tokens for better maintainability. - -```css -/* ✅ Good - semantic meaning */ ---cnvs-brand-primary-base: var(--cnvs-base-palette-blue-600); - -/* ❌ Avoid - direct base token usage */ ---cnvs-base-palette-blue-600: blue; -``` - -### 2. Test Accessibility - -Ensure color combinations meet accessibility standards. - -```css -/* Verify contrast ratios for text/background combinations */ -:root { - --cnvs-brand-primary-base: var(--cnvs-base-palette-blue-600); - --cnvs-brand-primary-accent: var(--cnvs-base-palette-neutral-0); /* White text */ -} -``` - -### 3. Avoid Component Level Theming - -Theming is meant to be done at the app level or root level of the application. Avoid theming at the -component level. +Popups (menus, selects, modals) portal to `document.body`. When `data-theme="sana-canvas"` is on +``, they inherit the global theme automatically. For scoped theming, pass +`sanaCanvasProviderTheme` at your root `CanvasProvider` so popups match: ```tsx -/* ✅ Good - App level theming */ - -const myCustomTheme = createStyles({ - [brand.primary.base]: base.magenta600 -}) - - - - +import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; -/* ❌ Avoid - wrapping components to theme */ - -const myCustomTheme = createStyles({ - [brand.primary.base]: base.magenta600 -}) - - - Click Me + + - -``` - -## Component Compatibility - -All Canvas Kit components in v14 automatically consume CSS variables. No component-level changes are -required when switching from the theme prop approach to CSS variables. - -### Supported Components - -- ✅ All Button variants (`PrimaryButton`, `SecondaryButton`, etc.) -- ✅ Form components (`TextInput`, `FormField`, etc.) -- ✅ Layout components (`Card`, `Modal`, etc.) -- ✅ Navigation components (`Tabs`, `SidePanel`, etc.) - -## Performance Benefits - -The CSS variable approach provides several performance improvements: - -- **Reduced Bundle Size**: No JavaScript theme object processing -- **Better Caching**: CSS variables can be cached by the browser -- **Faster Rendering**: Native CSS cascade instead of JavaScript calculations -- **Runtime Efficiency**: No theme context propagation overhead - -## Troubleshooting - -### Theme Not Applied - -Ensure CSS variable files are imported in the correct order. - -> **Note:** You should only import the CSS variables _once_ at the root level of your application. -> If your application renders within another environment that imports these and sets them, **do -> not** re import them. - -```css -/* Correct order */ -@import '@workday/canvas-tokens-web/css/base/_variables.css'; -@import '@workday/canvas-tokens-web/css/system/_variables.css'; -@import '@workday/canvas-tokens-web/css/brand/_variables.css'; -@import '@workday/canvas-tokens-web/css/component/_variables.css'; - -/* Your overrides after imports */ -:root { - --cnvs-brand-primary-base: var(--cnvs-base-palette-magenta-600); -} ``` -### Inconsistent Theming - -Check for CSS specificity issues. - -```css -/* Ensure your overrides have sufficient specificity */ -:root { - --cnvs-brand-primary-base: var(--cnvs-base-palette-blue-600) !important; -} - -/* Or use more specific selectors */ -.my-app { - --cnvs-brand-primary-base: var(--cnvs-base-palette-blue-600); -} -``` - -### Missing Token Values - -Verify all required CSS token files are imported and token names are correct. - -```tsx -// Check token availability in development -console.log(brand.primary.base); // Should output CSS variable name -``` - -## Conclusion - -The migration to CSS variables in Canvas Kit v14 provides a more performant, flexible, and -maintainable theming solution. By following this guide and best practices, you can successfully -migrate your applications and take advantage of the improved theming capabilities. +See **Features/Theming → Sana Canvas** in Storybook for a side-by-side comparison of global and +scoped branding. -For additional support and examples, refer to the Canvas Kit Storybook documentation and the -`@workday/canvas-tokens` [repository](https://github.com/Workday/canvas-tokens). +View token documentation +[here](https://workday.github.io/canvas-tokens/?path=/docs/docs-getting-started--docs). diff --git a/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md b/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md index faa5dfa806..2436d8132a 100644 --- a/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md +++ b/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md @@ -6,10 +6,81 @@ any questions. ## Why You Should Upgrade -Soon to be... +Canvas Kit v16 is a 1:1 component update that aligns every Canvas component to Sana's visual +styling. Components ship that styling regardless of theme — you do not need to opt into the Sana +Canvas theme for components to look correct. + +The Sana Canvas **theme** (CSS variables + `data-theme="sana-canvas"`) is a separate, opt-in step +that updates brand colors, neutrals, surfaces, and shapes at the application level. See +[Sana Canvas Theme](#sana-canvas-theme) below. + +## Sana Canvas Theme + +v16 components are Sana-aligned out of the box. To opt your **application** into the full Sana Canvas +theme — brand neutrals, surfaces, and shapes — import the Sana CSS variables and set +`data-theme="sana-canvas"` on ``. + +> **Note:** All visual updates in this guide apply to both the Default Canvas theme and the Sana +> Canvas theme unless specified otherwise. + +### Opting In + +Import the Sana variables **last** in your root CSS entry point. Sana's `[data-theme="sana-canvas"]` +selector and `:root` have equal specificity — source order determines the winner. + +```css +/* index.css — order matters */ +@import '@workday/canvas-tokens-web/css/base/_variables.css'; +@import '@workday/canvas-tokens-web/css/brand/_variables.css'; +@import '@workday/canvas-tokens-web/css/component/_variables.css'; +@import '@workday/canvas-tokens-web/css/system/_variables.css'; +/* Sana last: [data-theme="sana-canvas"] and :root have equal specificity (0,1,0), + so when both match the cascade falls back to source order. */ +@import '@workday/canvas-tokens-web/css/sana/_variables.css'; +``` + +Set the theme attribute on `` (not a nested element): + +```html + +``` + +> **Gotcha:** There is no `[data-theme="canvas"]` rule. The sana file only defines +> `[data-theme="sana-canvas"]` overrides, so removing the attribute is how you get classic Canvas — +> there's nothing to undo it with. + +### What Changes When You Opt In + +Things that change are things that use the primary brand token and the Sana neutral ramp. Verified +against `@workday/canvas-tokens-web/css/sana/_variables.css`: + +- **Brand primary consumers flip.** `--cnvs-sys-color-brand-accent-primary` and `-accent-action` + re-point from blue to `--cnvs-brand-neutral-975`, and `-brand-fg-primary-default/-strong` to + `--cnvs-brand-neutral-a900/-a950`. `PrimaryButton`, brand links, and selected states are what + visibly change. +- **Focus does not.** `--cnvs-sys-color-brand-focus-primary` and `-border-primary` stay `blue-500`. +- **`--cnvs-brand-primary-600` is intentionally _not_ redefined** — it stays the consumer's brand + hook. +- **The full neutral ramp is replaced** (`--cnvs-brand-neutral-*`, all steps plus alphas), plus + selected `critical`, `caution`, `positive`, shapes (`sm`, `xs`, `xxl`, `xxxl`), surfaces/overlays, + and chart ramps. + +### Scoped Theming + +The `CanvasProvider` theming updates in +[#4060](https://github.com/Workday/canvas-kit/pull/4060) are for **scoped** use cases — embedding +Canvas in another brand, multi-tenant sections, and popup parity. Application teams should not need +them if they import the Sana variables and set `data-theme="sana-canvas"` globally. + +For the scoped theming API, see our +[Theming documentation](https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs). ## Table of Contents +- [Sana Canvas Theme](#sana-canvas-theme) + - [Opting In](#opting-in) + - [What Changes When You Opt In](#what-changes-when-you-opt-in) + - [Scoped Theming](#scoped-theming) - [Codemod](#codemod) - [Instructions](#instructions) - [New Components](#new-components) @@ -292,8 +363,6 @@ If you customize `brand.success.base` in your theme, please verify the following - Prefer a `brand.success.base` value that is dark enough to pair with a light foreground. If your brand requires a light success color, you may need to override the component styles so the foreground uses a darker, paired contrast color instead of white. - > **Note:** All visual updates apply to both the Default Canvas theme and new Sana Canvas theme - > unless specified otherwise. #### Checkbox diff --git a/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx b/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx index df88623c48..45dab4a950 100644 --- a/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx +++ b/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx @@ -10,10 +10,83 @@ any questions. ## Why You Should Upgrade -Soon to be... +Canvas Kit v16 is a 1:1 component update that aligns every Canvas component to Sana's visual +styling. Components ship that styling regardless of theme — you do not need to opt into the Sana +Canvas theme for components to look correct. + +The Sana Canvas **theme** (CSS variables + `data-theme="sana-canvas"`) is a separate, opt-in step +that updates brand colors, neutrals, surfaces, and shapes at the application level. See +[Sana Canvas Theme](#sana-canvas-theme) below. + + +## Sana Canvas Theme + +v16 components are Sana-aligned out of the box. To opt your **application** into the full Sana Canvas +theme — brand neutrals, surfaces, and shapes — import the Sana CSS variables and set +`data-theme="sana-canvas"` on ``. + +> **Note:** All visual updates in this guide apply to both the Default Canvas theme and the Sana +> Canvas theme unless specified otherwise. + +### Opting In + +Import the Sana variables **last** in your root CSS entry point. Sana's `[data-theme="sana-canvas"]` +selector and `:root` have equal specificity — source order determines the winner. + +```css +/* index.css — order matters */ +@import '@workday/canvas-tokens-web/css/base/_variables.css'; +@import '@workday/canvas-tokens-web/css/brand/_variables.css'; +@import '@workday/canvas-tokens-web/css/component/_variables.css'; +@import '@workday/canvas-tokens-web/css/system/_variables.css'; +/* Sana last: [data-theme="sana-canvas"] and :root have equal specificity (0,1,0), + so when both match the cascade falls back to source order. */ +@import '@workday/canvas-tokens-web/css/sana/_variables.css'; +``` + +Set the theme attribute on `` (not a nested element): + +```html + +``` + +> **Gotcha:** There is no `[data-theme="canvas"]` rule. The sana file only defines +> `[data-theme="sana-canvas"]` overrides, so removing the attribute is how you get classic Canvas — +> there's nothing to undo it with. + + +### What Changes When You Opt In + +Things that change are things that use the primary brand token and the Sana neutral ramp. Verified +against `@workday/canvas-tokens-web/css/sana/_variables.css`: + +- **Brand primary consumers flip.** `--cnvs-sys-color-brand-accent-primary` and `-accent-action` + re-point from blue to `--cnvs-brand-neutral-975`, and `-brand-fg-primary-default/-strong` to + `--cnvs-brand-neutral-a900/-a950`. `PrimaryButton`, brand links, and selected states are what + visibly change. +- **Focus does not.** `--cnvs-sys-color-brand-focus-primary` and `-border-primary` stay `blue-500`. +- **`--cnvs-brand-primary-600` is intentionally _not_ redefined** — it stays the consumer's brand + hook. +- **The full neutral ramp is replaced** (`--cnvs-brand-neutral-*`, all steps plus alphas), plus + selected `critical`, `caution`, `positive`, shapes (`sm`, `xs`, `xxl`, `xxxl`), surfaces/overlays, + and chart ramps. + +### Scoped Theming + +The `CanvasProvider` theming updates in +[#4060](https://github.com/Workday/canvas-kit/pull/4060) are for **scoped** use cases — embedding +Canvas in another brand, multi-tenant sections, and popup parity. Application teams should not need +them if they import the Sana variables and set `data-theme="sana-canvas"` globally. + +For the scoped theming API, see our +[Theming documentation](https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs). ## Table of Contents +- [Sana Canvas Theme](#sana-canvas-theme) + - [Opting In](#opting-in) + - [What Changes When You Opt In](#what-changes-when-you-opt-in) + - [Scoped Theming](#scoped-theming) - [Codemod](#codemod) - [Instructions](#instructions) - [New Components](#new-components) @@ -296,8 +369,6 @@ If you customize `brand.success.base` in your theme, please verify the following - Prefer a `brand.success.base` value that is dark enough to pair with a light foreground. If your brand requires a light success color, you may need to override the component styles so the foreground uses a darker, paired contrast color instead of white. - > **Note:** All visual updates apply to both the Default Canvas theme and new Sana Canvas theme - > unless specified otherwise. #### Checkbox diff --git a/modules/react/common/lib/CanvasProvider.tsx b/modules/react/common/lib/CanvasProvider.tsx index f32e6388b8..ef3c7b5888 100644 --- a/modules/react/common/lib/CanvasProvider.tsx +++ b/modules/react/common/lib/CanvasProvider.tsx @@ -25,7 +25,7 @@ import { export interface CanvasProviderProps { /** * ⚠️ Only use this prop if you intent to to theme a part of your application that is different from global theming. - * For more information, view our [Theming Docs](https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs#global-vs-scoped-theming). + * For more information, view our [Theming Docs](https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs#scoped-theming). * * While we support theme overrides, we advise to use global theming via CSS Variables. */ diff --git a/modules/react/common/lib/theming/README.md b/modules/react/common/lib/theming/README.md index 42b4d327bc..9693c15863 100644 --- a/modules/react/common/lib/theming/README.md +++ b/modules/react/common/lib/theming/README.md @@ -6,6 +6,13 @@ > guide, see our > [Theming Documentation](https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs). +## Sana Canvas Theme + +For application-level theming in v16, import the Sana CSS variables and set `data-theme="sana-canvas"` +on ``. See the +[v16 Upgrade Guide](https://workday.github.io/canvas-kit/?path=/docs/guides-upgrade-guides-v-16-0-overview--docs#sana-canvas-theme) +for the canonical setup. The `CanvasProvider` `theme` prop is for **scoped** theming only. + ## Installation ```sh @@ -14,7 +21,7 @@ yarn add @workday/canvas-kit-react/common ## Recommended Approach: CSS Variables -Canvas Kit v14+ promotes using CSS variables for theming. Import CSS variable files and override +Canvas Kit v16 promotes using CSS variables for theming. Import CSS variable files and override values in your root CSS: ```css @@ -23,12 +30,13 @@ values in your root CSS: @import '@workday/canvas-tokens-web/css/system/_variables.css'; @import '@workday/canvas-tokens-web/css/brand/_variables.css'; @import '@workday/canvas-tokens-web/css/component/_variables.css'; +@import '@workday/canvas-tokens-web/css/sana/_variables.css'; :root { /* Override brand primary colors */ - --cnvs-brand-primary-base: var(--cnvs-base-palette-magenta-600); - --cnvs-brand-primary-light: var(--cnvs-base-palette-magenta-200); - --cnvs-brand-primary-dark: var(--cnvs-base-palette-magenta-700); + --cnvs-brand-primary-600: var(--cnvs-base-palette-magenta-600); + --cnvs-brand-primary-500: var(--cnvs-base-palette-magenta-500); + --cnvs-brand-primary-A50: var(--cnvs-base-palette-magenta-A50); } ``` @@ -40,8 +48,8 @@ import {createStyles} from '@workday/canvas-kit-styling'; import {base, brand} from '@workday/canvas-tokens-web'; const themedBrand = createStyles({ - [brand.primary.base]: base.legacy.magenta600, - [brand.primary.dark]: base.legacy.magenta700, + [brand.primary600]: base.magenta600, + [brand.primary700]: base.magenta700, }); @@ -49,6 +57,22 @@ const themedBrand = createStyles({ ; ``` +## Scoped Theming (CanvasProvider) + +For embedded or multi-brand sections, use the numerical `brand` shape: + +```tsx +import {CanvasProvider} from '@workday/canvas-kit-react/common'; +import {base} from '@workday/canvas-tokens-web'; + + + + +``` + +For popup parity when using global Sana CSS, pass `sanaCanvasProviderTheme` at your root +`CanvasProvider`. See the [Theming documentation](https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs) for details. + ## Bidirectionality (RTL Support) ### Setting RTL Direction diff --git a/modules/react/common/lib/theming/index.ts b/modules/react/common/lib/theming/index.ts index be0bd8834e..06bcd0e0c1 100644 --- a/modules/react/common/lib/theming/index.ts +++ b/modules/react/common/lib/theming/index.ts @@ -15,7 +15,6 @@ export {default as styled, type StyleRewriteFn, filterOutProps} from './styled'; */ export * from './theme'; export * from './sanaTheme'; -export * from './brandScope'; /** * @deprecated ⚠️ `useTheme` and `getTheme` are deprecated. Use CSS variables from `@workday/canvas-tokens-web` instead. * For more information, view our [Theming Docs](https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs#-preferred-approach-v14). diff --git a/modules/react/common/lib/theming/types.ts b/modules/react/common/lib/theming/types.ts index 154acca332..de8e827eb6 100644 --- a/modules/react/common/lib/theming/types.ts +++ b/modules/react/common/lib/theming/types.ts @@ -548,42 +548,3 @@ export function resolveThemingScope(theme: CanvasProviderTheme | undefined): Can return 'brand'; } - -export function isPrimaryOnlyInput(theme: CanvasProviderTheme | undefined): boolean { - if (!theme) { - return false; - } - if (isNumericalTheme(theme)) { - if (theme.system || theme.selected) { - return false; - } - const brand = theme.brand; - if (!brand) { - return false; - } - const hasNonPrimary = ['critical', 'caution', 'positive', 'neutral', 'action'].some( - k => brand[k as keyof typeof brand] != null - ); - if (hasNonPrimary) { - return false; - } - const primary = brand.primary; - if (!primary) { - return false; - } - const keys = Object.keys(primary); - return keys.length === 1 && keys[0] === '600'; - } - - const palette = theme.canvas?.palette; - if (!palette?.primary) { - return false; - } - const primaryKeys = Object.keys(palette.primary); - const hasOnlyMain = primaryKeys.length === 1 && palette.primary.main != null; - const hasCommon = palette.common != null && Object.keys(palette.common).length > 0; - const hasOtherColors = ['error', 'alert', 'success', 'neutral'].some( - c => palette[c as keyof typeof palette] != null - ); - return hasOnlyMain && !hasOtherColors && !hasCommon; -} diff --git a/modules/react/common/spec/theming-types.spec.ts b/modules/react/common/spec/theming-types.spec.ts index e30e8b5cad..bd26dd6920 100644 --- a/modules/react/common/spec/theming-types.spec.ts +++ b/modules/react/common/spec/theming-types.spec.ts @@ -1,4 +1,4 @@ -import {isNumericalTheme, isPrimaryOnlyInput, resolveThemingScope} from '../lib/theming/types'; +import {isNumericalTheme, resolveThemingScope} from '../lib/theming/types'; describe('isNumericalTheme', () => { it('is false for the deprecated shape', () => { @@ -38,17 +38,3 @@ describe('resolveThemingScope', () => { ); }); }); - -describe('isPrimaryOnlyInput', () => { - it('is true for semantic primary.main only', () => { - expect(isPrimaryOnlyInput({canvas: {palette: {primary: {main: 'red'}}}})).toBe(true); - }); - - it('is false when common tokens are set', () => { - expect( - isPrimaryOnlyInput({ - canvas: {palette: {primary: {main: 'red'}, common: {focusOutline: 'teal'}}}, - }) - ).toBe(false); - }); -}); diff --git a/modules/react/common/stories/mdx/Theming.mdx b/modules/react/common/stories/mdx/Theming.mdx index 7e51e62aba..5d20f01523 100644 --- a/modules/react/common/stories/mdx/Theming.mdx +++ b/modules/react/common/stories/mdx/Theming.mdx @@ -2,166 +2,80 @@ import {Meta} from '@storybook/blocks'; import {ExampleCodeBlock} from '@workday/canvas-kit-docs'; -import {RTL} from './examples/RTL'; -import {Theming} from './examples/Theming'; import {ThemingBrandScope} from './examples/ThemingBrandScope'; # Canvas Kit Theming Guide -## Overview +Canvas Kit v16 components are Sana-aligned out of the box. The Sana Canvas **theme** is a separate, +opt-in step that updates brand colors, neutrals, surfaces, and shapes at the application +level. -Canvas Kit v14 and v15 introduce a significant shift in our approach to theming: we've moved away -from JavaScript-based theme objects to CSS variables. This change provides better performance, -improved developer experience, and greater flexibility for theming applications. +For a full list of what changes when you opt in, see the +[v16 Upgrade Guide](https://workday.github.io/canvas-kit/?path=/docs/guides-upgrade-guides-v-16-0-overview--docs#sana-canvas-theme). -> **📌 Quick Start:** -> -> 1. **Import CSS variables once** at the root level of your application (e.g., in `index.css`) -> 2. **Override tokens at `:root`** for global theming — this is the recommended approach -> 3. **Use `CanvasProvider` scoped theming only** for specific scenarios like multi-brand sections -> or embedded components -> -> If your application renders within an environment that already imports these CSS variables, **do -> not re-import them**. +## Sana Canvas Theme -View our latest tokens documentation -[here](https://workday.github.io/canvas-tokens/?path=/docs/docs-getting-started--docs). - -## Migration from v10 Theme Prop to v14 CSS Variables - -### The Evolution - -**Canvas Kit v10** introduced CSS tokens through the `@workday/canvas-tokens-web` package, providing -a foundation for consistent design system values. - -**Canvas Kit v14** Removes the cascade barrier created by the `CanvasProvider`, allowing CSS -variables to work as intended. - -## Old Approach (v10-v13) - -The old theming approach used JavaScript objects passed to the `CanvasProvider` theme prop: - -```tsx -import {CanvasProvider} from '@workday/canvas-kit-react/common'; -import {base} from '@workday/canvas-tokens-web'; - - - -; -``` - -This would use `chroma.js` to generate a palette based on the `main` color provided. - -**Why we're moving away from this approach:** - -- Performance overhead from JavaScript theme object processing -- Limited flexibility for complex theming scenarios -- Inconsistent cascade behavior - -Any time `theme` is passed, the `CanvasProvider` would generate a palette and attach brand variables -via a `className` scoping those brand variables to a wrapping div. In order for us to provide a -better solution to theming that is scalable and is more aligned with our CSS variables, we changed -this approach. - -**Note:** While we support theme overrides, we advise to use global theming via CSS Variables. - -## What is a Cascade Barrier? - -When we say "cascade barrier", we're talking about how -[CSS cascades](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascade/Cascade) and takes -precedence. Take the following example: +Import the Sana variables **last** in your root CSS and set `data-theme="sana-canvas"` on ``. ```css -:root { - --cnbvs-brand-primary-base: blue; -} +/* index.css — order matters */ +@import '@workday/canvas-tokens-web/css/base/_variables.css'; +@import '@workday/canvas-tokens-web/css/brand/_variables.css'; +@import '@workday/canvas-tokens-web/css/component/_variables.css'; +@import '@workday/canvas-tokens-web/css/system/_variables.css'; +@import '@workday/canvas-tokens-web/css/sana/_variables.css'; -// the element with the class .my-app will have a higher specificity than root, creating a barrier where the CSS variables gets redefined and takes precedence over what is defined at root. -.my-app { - --cnvs-brand-primary-base: red; +:root { + /* Optional — override only if you have a custom brand color */ + --cnvs-brand-primary-600: var(--cnvs-base-palette-magenta-600); } ``` -In the case of the `CanvasProvider` prior to v14, all our brand tokens where defined within a class -and scoped to the `div` that the `CanvasProvider` created. This meant that anything set on `:root` -or outside of the `CanvasProvider` would not be able to cascade down to the components within the -`CanvasProvider`. - -If you provide a `theme` to the `CanvasProvider`, it will create a scoped theme. Note that in v14 -and v15, global CSS variables are the recommended way to theme Popups and Modals consistently. - -## Global vs Scoped Theming - -Canvas Kit v14 and v15 support two theming strategies: **global theming** and **scoped theming**. -Understanding the difference is important to avoid unexpected behavior. +```html + +``` -### Global Theming +## Classic Canvas (without Sana theme) -Global theming applies CSS variables at the `:root` level, making them available throughout your -entire application. This is the **recommended approach** for most use cases. +If you are not opting into the Sana Canvas theme, omit `data-theme` from ``. The `:root` +tokens apply as-is — no `theme` prop on `CanvasProvider` is required. -```css -@import '@workday/canvas-tokens-web/css/base/_variables.css'; -:root { - // This is showing how you can change the value of a token at the root level of your application. - --cnvs-brand-primary-600: var(--cnvs-base-palette-magenta-600); -} +```html + ``` -### Scoped Theming - -Scoped theming applies CSS variables to a specific section of your application using the -`CanvasProvider` via the `theme` prop. The theme only affects components within that provider. - ```tsx -// Using the theme prop for scoped theming. This will set the [brand.primary.**] tokens to shades of purple. This will also ensure that the Popup and Modal components are themed consistently. - - +import {CanvasProvider} from '@workday/canvas-kit-react/common'; + + + ``` -> **⚠️ Warning:** Scoped theming creates a cascade barrier that **will break global theming**. Any -> CSS variables defined at `:root` will be overridden by the scoped theme. Only the tokens -> explicitly defined in the `theme` prop will be changed - other tokens will use their default -> values, not your global overrides. - -### When to Use Scoped Theming - -Only use scoped theming when you intentionally need a different theme for a specific section of your -application, such as: - -- Embedding a Canvas Kit component in a third-party application with a different brand -- Creating a preview panel that shows components with different themes -- Supporting multi-tenant applications where sections have different branding +The sana stylesheet only defines `[data-theme="sana-canvas"]` overrides. Without that attribute, +those rules do not apply. -For all other cases, use global theming at `:root` to ensure consistent theming throughout your -application. +If your application **has** opted into Sana globally but one subsection needs classic Canvas branding, +use `defaultBranding` on a scoped `CanvasProvider`: -## Global Theme Import Order (Sana Canvas) - -When using Sana Canvas, import `@workday/canvas-tokens-web/css/sana/_variables.css` **after** -base, brand, and system in a single root entry point. Sana's `[data-theme="sana-canvas"]` selector -and `:root` rules have equal specificity — source order determines the winner. Importing sana last -is required and sufficient. +```tsx +import {CanvasProvider, defaultBranding} from '@workday/canvas-kit-react/common'; -```css -@import '@workday/canvas-tokens-web/css/base/_variables.css'; -@import '@workday/canvas-tokens-web/css/brand/_variables.css'; -@import '@workday/canvas-tokens-web/css/system/_variables.css'; -/* Sana last — wins the cascade tie when data-theme="sana-canvas" is on */ -@import '@workday/canvas-tokens-web/css/sana/_variables.css'; + + + ``` -Scoped `CanvasProvider` theming is unrelated to this import order. +## Scoped Theming -## Brand Theme API (`CanvasProvider`) +Most application teams should use the Sana Canvas theme globally and not pass a `theme` prop to +`CanvasProvider`. Use scoped theming only when a section of your app needs a different brand — for +example, embedding Canvas in a third-party application, multi-tenant branding, or popup parity. -The preferred `theme` shape uses a numerical `brand` object. Each key maps 1:1 to a -`--cnvs-brand-*` CSS variable unless noted as a shortcut below. - -### What is themable? +The `theme` prop accepts a numerical `brand` object. Each key maps 1:1 to a `--cnvs-brand-*` CSS +variable. | You set | Components affected | | ------- | ------------------- | @@ -175,50 +89,22 @@ The preferred `theme` shape uses a numerical `brand` object. Each key maps 1:1 t | `selected.fg` / `selected.surface` | Selected list/menu states directly | **Focus does not follow primary.** Setting only `brand.primary['600']` leaves focus rings at the -default blue unless you also set `brand.primary['500']` or `canvas.palette.common.focusOutline`. - -### Minimal example (brand scope — default) +default blue unless you also set `brand.primary['500']`. ```tsx import {CanvasProvider} from '@workday/canvas-kit-react/common'; import {base} from '@workday/canvas-tokens-web'; - + ``` -### Explicit overrides - -```tsx - - - -``` - -### `themeScope` - -| Value | Numerical `brand` shape | Legacy `canvas.palette` shape | -| ----- | ----------------------- | ------------------------------ | -| `'brand'` (default) | `primary['600']` shortcut → buttons + selected; other keys literal 1:1 | `primary.main` shortcut only | -| `'full'` | All keys literal 1:1, no shortcut | Auto-generated ramps + broad system token forwarding | - -Use `themeScope="full"` on the legacy shape only when you need the old auto-ramp behavior. - -### Sana Canvas preset - -Sana's full visual treatment (fonts, shapes, system surfaces) comes from **global CSS** -(`data-theme="sana-canvas"` + `sana/_variables.css`). For **popup parity** (menus, selects), also -pass the JS preset at your root `CanvasProvider`: +Popups (menus, selects, modals) portal to `document.body`. When `data-theme="sana-canvas"` is on +``, they inherit the global theme automatically. For scoped theming, pass +`sanaCanvasProviderTheme` at your root `CanvasProvider` so popups match: ```tsx import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; @@ -228,381 +114,8 @@ import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react ``` -### Visual testing - -Use the **Features/Theming → Sana Canvas** and **Canvas** stories to compare global vs scoped -branding side-by-side. Open **Canvas** with `?theme=canvas` in the URL. - -### Migration: semantic → numerical - -| Old (`canvas.palette`) | New (`brand`) | -| ---------------------- | ------------- | -| `palette.primary.main` | `brand.primary['600']` | -| `palette.primary.dark` (selected text) | `selected.fg` or `brand.primary['700']` | -| `palette.primary.lighter` (selected bg) | `selected.surface` or `brand.primary.A50` | -| `palette.common.focusOutline` | `brand.primary['500']` (independent of `600`) | -| `palette.error.*` | `brand.critical.*` | -| `palette.alert.*` | `brand.caution.*` | -| `palette.success.*` | `brand.positive.*` | -| Full auto-generated ramp | `themeScope="full"` on legacy shape | - -## ✅ Preferred Approach (v14+) - -Canvas Kit v14 and v15 promote using CSS variables for theming, which can be applied in two ways: - -### Method 1: Global CSS Variables (Recommended) - -Apply theming at the global level by importing CSS variable files and overriding values in your root -CSS: - -```css -/* index.css */ -@import '@workday/canvas-tokens-web/css/base/_variables.css'; -@import '@workday/canvas-tokens-web/css/system/_variables.css'; -@import '@workday/canvas-tokens-web/css/brand/_variables.css'; -@import '@workday/canvas-tokens-web/css/component/_variables.css'; - -:root { - /* Override brand primary colors */ - --cnvs-brand-primary-600: var(--cnvs-base-palette-magenta-600); - --cnvs-brand-primary-200: var(--cnvs-base-palette-magenta-200); - --cnvs-brand-primary-50: var(--cnvs-base-palette-magenta-50); - --cnvs-brand-primary-25: var(--cnvs-base-palette-magenta-25); - --cnvs-brand-primary-700: var(--cnvs-base-palette-magenta-700); - --cnvs-brand-primary-800: var(--cnvs-base-palette-magenta-800); -} -``` - -> **Note:** You should only import the CSS variables _once_ at the root level of your application. -> If your application renders within another environment that imports these and sets them, **do -> not** re import them. - -### Method 2: Provider-Level CSS Variables - -Use Canvas Kit's `CanvasProvider` and `theme` prop to generate themed class names that can be -applied to specific components or sections: - -```tsx -import {CanvasProvider} from '@workday/canvas-kit-react/common'; - -// This will set the [brand.primary.**] tokens to shades of purple. This will also ensure that the Popup and Modal components are themed consistently. - - -; -``` - -## CSS Token Structure - -Canvas Kit provides three layers of CSS variables. - -### Base Tokens (`base/_variables.css`) - -Base tokens define foundation palette and design values. - -```css ---cnvs-base-palette-blue-600: oklch(0.5198 0.1782 256.11 / 1); ---cnvs-base-palette-magenta-600: oklch(0.534 0.183 344.19 / 1); ---cnvs-base-font-size-100: 1rem; ---cnvs-base-space-x4: calc(var(--cnvs-base-unit) * 4); -``` - -### Brand Tokens (`brand/_variables.css`) - -Brand tokens define semantic color assignments. +See **Features/Theming → Sana Canvas** in Storybook for a side-by-side comparison of global and +scoped branding. -```css ---cnvs-brand-primary-600: var(--cnvs-base-palette-blue-600); ---cnvs-brand-primary-200: var(--cnvs-base-palette-blue-200); ---cnvs-brand-primary-50: var(--cnvs-base-palette-blue-50); ---cnvs-brand-primary-25: var(--cnvs-base-palette-blue-25); ---cnvs-brand-primary-700: var(--cnvs-base-palette-blue-700); ---cnvs-brand-primary-800: var(--cnvs-base-palette-blue-800); -``` - -### System Tokens (`system/_variables.css`) - -System tokens define component-specific values. - -```css ---cnvs-sys-color-bg-default: var(--cnvs-base-palette-blue-600); ---cnvs-sys-shape-sm: var(--cnvs-base-size-50); -``` - -## Practical Examples - -### Complete Brand Theming - -```css -/* themes/magenta-theme.css */ -@import '@workday/canvas-tokens-web/css/base/_variables.css'; -@import '@workday/canvas-tokens-web/css/system/_variables.css'; -@import '@workday/canvas-tokens-web/css/brand/_variables.css'; - -:root { - /* Primary brand colors */ - --cnvs-brand-primary-600: var(--cnvs-base-palette-magenta-600); - --cnvs-brand-primary-200: var(--cnvs-base-palette-magenta-200); - --cnvs-brand-primary-50: var(--cnvs-base-palette-magenta-50); - --cnvs-brand-primary-25: var(--cnvs-base-palette-magenta-25); - --cnvs-brand-primary-700: var(--cnvs-base-palette-magenta-700); - --cnvs-brand-primary-800: var(--cnvs-base-palette-magenta-800); -} -``` - -### Scoped Theming - - - -### RTL Support - -Canvas Kit supports RTL out of the box. Our components are styled to use -[CSS logical properties](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_logical_properties_and_values). -If you want to add additional styles based on RTL, you can also use the `:dir` -[pseudo selector](https://developer.mozilla.org/en-US/docs/Web/CSS/:dir). - -#### Setting RTL Direction - -Use the native HTML `dir` attribute to set the text direction. The `CanvasProvider` accepts a `dir` -prop which sets this attribute on its wrapper element: - -```tsx -import {CanvasProvider} from '@workday/canvas-kit-react/common'; - -// Set RTL direction - - -; -``` - -You can also set it on any HTML element: - -```tsx -
- -
-``` - -> **Note:** The `dir` attribute is the standard HTML way to set text direction. It's preferred over -> the deprecated `theme.canvas.direction` approach because it works natively with CSS logical -> properties and the `:dir()` pseudo-class. - -#### Using CSS Logical Properties - -CSS logical properties automatically adapt to the text direction. Use these instead of physical -properties: - -```css -/* Physical properties (don't adapt to RTL) */ -.my-component { - margin-left: 1rem; - padding-right: 1rem; - border-left: 1px solid; -} - -/* Logical properties (adapt to RTL automatically) */ -.my-component { - margin-inline-start: 1rem; - padding-inline-end: 1rem; - border-inline-start: 1px solid; -} -``` - -#### Conditional RTL Styles with `:dir()` - -For styles that need to change based on direction (like rotating icons), use the `:dir()` -pseudo-class: - -```tsx -import {createStyles} from '@workday/canvas-kit-styling'; - -const rtlButtonStyles = createStyles({ - ':dir(rtl)': { - svg: { - transform: 'rotate(180deg)', - }, - }, -}); -``` - - - -### Resetting to Default Brand Theme - -If you need to reset the theme in parts of your application, there's a few ways to do this. We -export a `defaultBranding` class that can be applied to the `CanvasProvider` which can wrap parts of -your application. - -```tsx -import {CanvasProvider, defaultBranding} from '@workday/canvas-kit-react/common'; - - - -; -``` - -> **Note:** Doing the following **will create a cascade barrier**. Only use this method if you -> intentionally want to override the default theme. - -## Migration Guide - -### Step 1: Identify Current Theme Usage - -Find all instances of `CanvasProvider` with theme props in your application. - -```tsx -// Find these patterns: - -``` - -### Step 2: Extract Theme Values - -Convert JavaScript theme objects to CSS variable overrides. - -```tsx -// Old approach: -const theme = { - canvas: { - palette: { - primary: { - main: colors.greenApple400, - dark: colors.greenApple500, - } - } - } -}; - -// New approach - CSS variables: -:root { - --cnvs-brand-primary-base: var(--cnvs-base-palette-green-400); - --cnvs-brand-primary-dark: var(--cnvs-base-palette-green-500); -} -``` - -### Step 3: App Level Theming Usage - -Replace theme-based `CanvasProvider` usage with CSS class-based theming. - -```tsx - - - -``` - -## Best Practices - -### 1. Use Semantic Token Names - -Use brand tokens instead of base tokens for better maintainability. - -```css -/* ✅ Good - semantic meaning */ ---cnvs-brand-primary-600: var(--cnvs-base-palette-blue-600); - -/* ❌ Avoid - direct base token usage */ ---cnvs-base-palette-blue-600: blue; -``` - -### 2. Test Accessibility - -Ensure color combinations meet accessibility standards. - -For a full list of color contrast pairs, view our -[Color Contrast](https://canvas.workday.com/guidelines/color/color-contrast) documentation. - -### 3. Avoid Component Level Theming - -Theming is meant to be done at the app level or root level of the application. Avoid theming at the -component level. - -```tsx -/* ✅ Good - App level theming */ -import {CanvasProvider} from '@workday/canvas-kit-react/common'; - -import {base, brand} from '@workday/canvas-tokens-web'; - - - - - - -/* ❌ Avoid - wrapping components to theme */ -import {CanvasProvider} from '@workday/canvas-kit-react/common'; -import {PrimaryButton} from '@workday/canvas-kit-react/button'; - -const myCustomTheme = createStyles({ - [brand.primary.base]: base.magenta600 -}) - - - Click Me - - -``` - -## Performance Benefits - -The CSS variable approach provides several performance improvements: - -- **Reduced Bundle Size**: No JavaScript theme object processing -- **Better Caching**: CSS variables can be cached by the browser -- **Faster Rendering**: Native CSS cascade instead of JavaScript calculations -- **Runtime Efficiency**: No theme context propagation overhead - -## Troubleshooting - -### Theme Not Applied - -Ensure CSS variable files are imported in the correct order. - -> **Note:** You should only import the CSS variables _once_ at the root level of your application. -> If your application renders within another environment that imports these and sets them, **do -> not** re import them. - -```css -/* Correct order */ -@import '@workday/canvas-tokens-web/css/base/_variables.css'; -@import '@workday/canvas-tokens-web/css/system/_variables.css'; -@import '@workday/canvas-tokens-web/css/brand/_variables.css'; -@import '@workday/canvas-tokens-web/css/component/_variables.css'; - -/* Your overrides after imports */ -:root { - --cnvs-brand-primary-base: var(--cnvs-base-palette-magenta-600); -} -``` - -### Inconsistent Theming - -Check for CSS specificity issues. - -```css -/* Ensure your overrides have sufficient specificity */ -:root { - --cnvs-brand-primary-base: var(--cnvs-base-palette-blue-600) !important; -} - -/* Or use more specific selectors */ -.my-app { - --cnvs-brand-primary-base: var(--cnvs-base-palette-blue-600); -} -``` - -### Missing Token Values - -Verify all required CSS token files are imported and token names are correct. - -```tsx -import {base, brand, system} from '@workday/canvas-tokens-web'; - -// Check token availability in development -console.log(brand.primary.base); // Should output CSS variable name -``` - -## Conclusion - -The migration to CSS variables in Canvas Kit v14 provides a more performant, flexible, and -maintainable theming solution. By following this guide and best practices, you can successfully -migrate your applications and take advantage of the improved theming capabilities. - -For additional support and examples, refer to the Canvas Kit Storybook documentation and the -`@workday/canvas-tokens` [repository](https://github.com/Workday/canvas-tokens). +View token documentation +[here](https://workday.github.io/canvas-tokens/?path=/docs/docs-getting-started--docs). diff --git a/modules/react/common/stories/mdx/examples/ThemingBrandScope.tsx b/modules/react/common/stories/mdx/examples/ThemingBrandScope.tsx index 5401a38ecc..13026588f1 100644 --- a/modules/react/common/stories/mdx/examples/ThemingBrandScope.tsx +++ b/modules/react/common/stories/mdx/examples/ThemingBrandScope.tsx @@ -5,7 +5,12 @@ import {Menu} from '@workday/canvas-kit-react/menu'; import {base} from '@workday/canvas-tokens-web'; export const ThemingBrandScope = () => ( - + Brand scope @@ -17,7 +22,9 @@ export const ThemingBrandScope = () => ( Other item - Selected item + + Selected item + diff --git a/modules/react/common/stories/theming/ThemeComparison.stories.tsx b/modules/react/common/stories/theming/ThemeComparison.stories.tsx index 88bc8f5366..4b03545d59 100644 --- a/modules/react/common/stories/theming/ThemeComparison.stories.tsx +++ b/modules/react/common/stories/theming/ThemeComparison.stories.tsx @@ -9,8 +9,8 @@ import {brandScopePrimaryOnly, primaryWithFocus} from '../../../../../utils/stor import {BrandingFixture} from './examples/BrandingFixture'; const rowStyles = createStyles({ - // gap: system.gap.xl, - // flexWrap: 'wrap', + gap: system.gap.xl, + flexWrap: 'wrap', }); export default { @@ -20,12 +20,12 @@ export default { const comparisonRender = () => ( - {/* - */} + ); diff --git a/modules/react/common/stories/theming/examples/BrandingFixture.tsx b/modules/react/common/stories/theming/examples/BrandingFixture.tsx index 99ea27c7ad..a89f15673f 100644 --- a/modules/react/common/stories/theming/examples/BrandingFixture.tsx +++ b/modules/react/common/stories/theming/examples/BrandingFixture.tsx @@ -47,18 +47,14 @@ const columnStyles = createStyles({ }); export const BrandingFixture = ({label, scopedTheme}: BrandingFixtureProps) => { - return ( + const content = ( {label} - {/* */} - {/* */} Item 1 Item 2 Item 3 - {/* */} - {/* */} @@ -209,4 +205,10 @@ export const BrandingFixture = ({label, scopedTheme}: BrandingFixtureProps) => { ); + + if (scopedTheme) { + return {content}; + } + + return content; }; diff --git a/modules/react/testing/lib/StaticStates.tsx b/modules/react/testing/lib/StaticStates.tsx index 11cb1a5121..ed60772063 100644 --- a/modules/react/testing/lib/StaticStates.tsx +++ b/modules/react/testing/lib/StaticStates.tsx @@ -45,7 +45,7 @@ export const StaticStates: React.FC< return ( - + {children} From 592b2aa8b824aa6b8fd91a1e5862a84e3ba58a94 Mon Sep 17 00:00:00 2001 From: "manuel.carrera" Date: Fri, 31 Jul 2026 09:33:58 -0600 Subject: [PATCH 05/19] fix: Fix type errors --- modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx | 26 +++ modules/docs/package.json | 2 +- modules/labs-react/package.json | 2 +- modules/preview-react/package.json | 2 +- modules/react/common/lib/CanvasProvider.tsx | 33 ++- .../react/common/lib/theming/brandScope.ts | 155 +++++++++++-- modules/react/common/lib/theming/sanaTheme.ts | 20 +- modules/react/common/lib/theming/types.ts | 16 +- modules/react/common/spec/brandScope.spec.ts | 84 ++++++- modules/react/common/spec/sanaTheme.spec.ts | 10 +- .../react/common/spec/theming-types.spec.ts | 8 + .../spec/useCanvasThemeToCssVars.spec.tsx | 19 ++ modules/react/package.json | 2 +- .../react/popup/lib/hooks/usePopupStack.ts | 12 +- .../react/popup/spec/usePopupStack.spec.tsx | 207 ++++++++++++++++++ modules/react/testing/lib/StaticStates.tsx | 8 +- .../stories/visualTesting.stories.tsx | 16 +- .../stories/visualTesting.stories.tsx | 15 +- modules/styling-transform/package.json | 2 +- .../spec/utils/handleColorSpace.spec.ts | 83 +++++++ modules/styling/package.json | 2 +- package.json | 2 +- utils/storybook/customThemes.ts | 60 ++++- utils/storybook/index.ts | 2 +- yarn.lock | 8 +- 25 files changed, 721 insertions(+), 75 deletions(-) create mode 100644 modules/react/popup/spec/usePopupStack.spec.tsx create mode 100644 modules/styling-transform/spec/utils/handleColorSpace.spec.ts diff --git a/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx b/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx index 5a87348d16..44e2e215fa 100644 --- a/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx +++ b/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx @@ -81,6 +81,32 @@ them if they import the Sana variables and set `data-theme="sana-canvas"` global For the scoped theming API, see our [Theming documentation](https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs). +#### Default Scope Change for Legacy Themes + +**Important:** If you previously used `canvas.palette..main` only (without other palette properties) +to scope-theme your application, the default behavior has changed. In v16: + +- **Before v16:** Setting only `palette.primary.main` would automatically generate a full color ramp + (lightest, lighter, light, dark, darkest, contrast) and apply broad `system.color.brand.*` forwarding. +- **In v16:** Setting only `palette.primary.main` defaults to `'brand'` scope, which applies a narrower + set of variables (PrimaryButton and selected states only). + +To restore the previous behavior, explicitly set `themeScope: 'full'`: + +```jsx +// v15 behavior (implicit full scope) + + +// v16 - to get the same behavior as v15 + +``` + +This change provides more control over theming scope and prevents unintended overrides, but teams +relying on the auto-generated color ramp need to explicitly opt into `'full'` scope. + ## Table of Contents - [Sana Canvas Theme](#sana-canvas-theme) diff --git a/modules/docs/package.json b/modules/docs/package.json index c3594241b0..abcdbee001 100644 --- a/modules/docs/package.json +++ b/modules/docs/package.json @@ -52,7 +52,7 @@ "@workday/canvas-kit-react": "^15.1.4", "@workday/canvas-kit-styling": "^15.1.4", "@workday/canvas-system-icons-web": "5.0.1", - "@workday/canvas-tokens-web": "4.4.0-beta.11", + "@workday/canvas-tokens-web": "4.4.0", "markdown-to-jsx": "^7.2.0", "react-syntax-highlighter": "^15.5.0", "ts-node": "^10.9.1" diff --git a/modules/labs-react/package.json b/modules/labs-react/package.json index 1388d7323f..790cb94344 100644 --- a/modules/labs-react/package.json +++ b/modules/labs-react/package.json @@ -51,7 +51,7 @@ "@workday/canvas-kit-react": "^15.1.4", "@workday/canvas-kit-styling": "^15.1.4", "@workday/canvas-system-icons-web": "5.0.1", - "@workday/canvas-tokens-web": "4.4.0-beta.11", + "@workday/canvas-tokens-web": "4.4.0", "@workday/design-assets-types": "^0.3.0", "chroma-js": "^2.2.0", "lodash.flatten": "^4.4.0", diff --git a/modules/preview-react/package.json b/modules/preview-react/package.json index 26c54785c2..9c756fbd50 100644 --- a/modules/preview-react/package.json +++ b/modules/preview-react/package.json @@ -51,7 +51,7 @@ "@workday/canvas-kit-react": "^15.1.4", "@workday/canvas-kit-styling": "^15.1.4", "@workday/canvas-system-icons-web": "5.0.1", - "@workday/canvas-tokens-web": "4.4.0-beta.11", + "@workday/canvas-tokens-web": "4.4.0", "@workday/design-assets-types": "^0.3.0" }, "devDependencies": { diff --git a/modules/react/common/lib/CanvasProvider.tsx b/modules/react/common/lib/CanvasProvider.tsx index ef3c7b5888..9b69f82636 100644 --- a/modules/react/common/lib/CanvasProvider.tsx +++ b/modules/react/common/lib/CanvasProvider.tsx @@ -22,6 +22,14 @@ import { writeSemanticTheme, } from './theming/brandScope'; +/** + * Context for providing brand CSS variables to popup containers. + * This carries the resolved CSS variable style map from CanvasProvider + * to usePopupStack for proper popup theming, regardless of whether + * a numerical or legacy theme is used. + */ +export const CanvasBrandStyleContext = React.createContext({}); + export interface CanvasProviderProps { /** * ⚠️ Only use this prop if you intent to to theme a part of your application that is different from global theming. @@ -187,9 +195,19 @@ export const CanvasProvider = ({ themeScope, ...props }: CanvasProviderProps & React.HTMLAttributes) => { - const {className, ...elemProps} = useCanvasThemeToCssVars(theme, props, themeScope); + const {className, style, ...elemProps} = useCanvasThemeToCssVars(theme, props, themeScope); const cache = getCache(); const rest = {...elemProps, ...props}; + + // Read parent context to support nested scoped providers + const parentBrandStyle = React.useContext(CanvasBrandStyleContext); + + // Merge parent style with current style, with current taking precedence + const mergedBrandStyle = React.useMemo( + () => ({...parentBrandStyle, ...style}), + [parentBrandStyle, style] + ); + const emotionTheme = theme ? isNumericalTheme(theme) ? ({canvas: defaultCanvasTheme} as Theme) @@ -202,15 +220,26 @@ export const CanvasProvider = ({ ? theme.direction || defaultCanvasTheme.direction : theme?.canvas?.direction || defaultCanvasTheme.direction } + style={style} {...(rest as React.HTMLAttributes)} > {children}
); + const wrappedContent = ( + + {content} + + ); + return ( - {emotionTheme ? {content} : content} + {emotionTheme ? ( + {wrappedContent} + ) : ( + wrappedContent + )} ); }; diff --git a/modules/react/common/lib/theming/brandScope.ts b/modules/react/common/lib/theming/brandScope.ts index e9c3b126fa..4f22b6b3ce 100644 --- a/modules/react/common/lib/theming/brandScope.ts +++ b/modules/react/common/lib/theming/brandScope.ts @@ -5,6 +5,7 @@ import {brand, system} from '@workday/canvas-tokens-web'; import {defaultCanvasTheme} from './theme'; import { + CanvasActionBrandRamp, CanvasBrandRamp, CanvasNumericalBrandTheme, CanvasProviderTheme, @@ -68,6 +69,13 @@ const brandColorMapping: Record = { neutral: 'neutral', }; +/** Brand ramp keys defined in Sana CSS but not yet exported from canvas-tokens-web. */ +const EXTENDED_BRAND_TOKEN_MAP: Record = { + neutral150: '--cnvs-brand-neutral-150', + neutral850: '--cnvs-brand-neutral-850', + neutralA150: '--cnvs-brand-neutral-a150', +}; + const setStyleVar = (style: React.CSSProperties, token: string, value: string) => { // @ts-ignore - CSS custom property key style[token] = maybeWrapCSSVariables(value); @@ -76,18 +84,18 @@ const setStyleVar = (style: React.CSSProperties, token: string, value: string) = /** Maps a numerical brand ramp onto `brand.` CSS variables. */ export function writeNumericalBrandRamp( color: BrandColor | 'action', - ramp: CanvasBrandRamp | undefined, + ramp: CanvasBrandRamp | CanvasActionBrandRamp | undefined, style: React.CSSProperties, options?: {skipKeys?: Set} ) { if (!ramp) { return; } - (Object.keys(ramp) as Array).forEach(rampKey => { + (Object.keys(ramp) as Array).forEach(rampKey => { if (options?.skipKeys?.has(rampKey)) { return; } - const value = ramp[rampKey]; + const value = ramp[rampKey as keyof typeof ramp]; if (value == null) { return; } @@ -95,7 +103,7 @@ export function writeNumericalBrandRamp( color === 'action' ? brand.action[rampKey as keyof typeof brand.action] : // @ts-ignore - dynamic token lookup - brand[`${color}${rampKey}`]; + (brand[`${color}${rampKey}`] ?? EXTENDED_BRAND_TOKEN_MAP[`${color}${rampKey}`]); if (token) { setStyleVar(style, token, value); } @@ -117,11 +125,27 @@ export function applyPrimaryBrandBundle(primaryColor: string, style: React.CSSPr setStyleVar(style, system.color.brand.accent.action, value); } + const selectedFg = colorSpace.pressed({ + color: `var(${brand.primary600})`, + fallback: value, + colorType: 'accent', + }); + const selectedSurface = colorSpace.darken({ + color: system.color.bg.default, + fallback: 'white', + mixinColor: `var(${brand.primary600})`, + mixinValue: '0.11', + }); + + setStyleVar(style, brand.primary700, selectedFg); + setStyleVar(style, brand.primary.dark, selectedFg); + setStyleVar(style, brand.primaryA50, selectedSurface); + if (BRAND_SCOPE_PRIMARY_BUNDLE.selected.fg) { - setStyleVar(style, BRAND_SCOPE_PRIMARY_BUNDLE.selected.fg, `var(${brand.primary700})`); + setStyleVar(style, BRAND_SCOPE_PRIMARY_BUNDLE.selected.fg, selectedFg); } if (BRAND_SCOPE_PRIMARY_BUNDLE.selected.surface) { - setStyleVar(style, BRAND_SCOPE_PRIMARY_BUNDLE.selected.surface, `var(${brand.primaryA50})`); + setStyleVar(style, BRAND_SCOPE_PRIMARY_BUNDLE.selected.surface, selectedSurface); } const hoverColor = colorSpace.hover({ @@ -138,6 +162,68 @@ export function applyPrimaryBrandBundle(primaryColor: string, style: React.CSSPr setStyleVar(style, brand.action.darkest, pressedColor); } +/** Called when consumer sets only `error.main` or `brand.critical['600']`. */ +export function applyCriticalBrandBundle(criticalColor: string, style: React.CSSProperties) { + const value = maybeWrapCSSVariables(criticalColor); + + setStyleVar(style, brand.error.base, value); + setStyleVar(style, brand.critical600, value); + setStyleVar(style, brand.critical500, value); + + if (system.color.brand.accent.critical) { + setStyleVar(style, system.color.brand.accent.critical, value); + } + if (system.color.brand.fg.critical?.default) { + setStyleVar(style, system.color.brand.fg.critical.default, value); + } + if (system.color.brand.border.critical) { + setStyleVar(style, system.color.brand.border.critical, value); + } + if (system.color.brand.focus.critical) { + setStyleVar(style, system.color.brand.focus.critical, value); + } +} + +/** Called when consumer sets only `alert.main` or `brand.caution['400']`. */ +export function applyCautionBrandBundle(cautionColor: string, style: React.CSSProperties) { + const value = maybeWrapCSSVariables(cautionColor); + + setStyleVar(style, brand.alert.base, value); + setStyleVar(style, brand.caution400, value); + setStyleVar(style, brand.caution500, value); + + if (system.color.brand.accent.caution) { + setStyleVar(style, system.color.brand.accent.caution, value); + } + if (system.color.brand.fg.caution?.default) { + setStyleVar(style, system.color.brand.fg.caution.default, value); + } + if (system.color.brand.border.caution) { + setStyleVar(style, system.color.brand.border.caution, value); + } + if (system.color.brand.focus.caution?.inner) { + setStyleVar(style, system.color.brand.focus.caution.inner, value); + } + if (system.color.brand.focus.caution?.outer) { + setStyleVar(style, system.color.brand.focus.caution.outer, value); + } +} + +/** Called when consumer sets only `success.main` or `brand.positive['600']`. */ +export function applyPositiveBrandBundle(positiveColor: string, style: React.CSSProperties) { + const value = maybeWrapCSSVariables(positiveColor); + + setStyleVar(style, brand.success.base, value); + setStyleVar(style, brand.positive600, value); + + if (system.color.brand.accent.positive) { + setStyleVar(style, system.color.brand.accent.positive, value); + } + if (system.color.brand.fg.positive?.default) { + setStyleVar(style, system.color.brand.fg.positive.default, value); + } +} + /** Writes first-class selected shortcuts from numerical theme input. */ export function writeSelectedShortcuts( selected: CanvasNumericalBrandTheme['selected'] | undefined, @@ -391,22 +477,47 @@ export function writeSemanticTheme( }); } -/** Writes numerical theme — brand scope applies primary shortcut; full scope is literal 1:1 only. */ +/** Writes numerical theme — brand scope applies per-family shortcuts; remaining keys write 1:1. */ export function writeNumericalTheme( theme: CanvasNumericalBrandTheme, style: React.CSSProperties, scope: 'brand' | 'full' ) { - const primaryRamp = theme.brand?.primary; - const primaryOnly = - scope === 'brand' && primaryRamp?.['600'] && Object.keys(primaryRamp).length === 1; + const brandPalette = theme.brand; + const skipRamp: Partial>> = {}; + + if (scope === 'brand' && brandPalette) { + if (brandPalette.primary?.['600'] && Object.keys(brandPalette.primary).length === 1) { + applyPrimaryBrandBundle(brandPalette.primary['600'], style); + skipRamp.primary = new Set(['600']); + skipRamp.action = new Set(['base', 'dark', 'darkest']); + } + const critical = brandPalette.critical; + if (critical && Object.keys(critical).length === 1) { + const criticalColor = critical['600'] ?? critical['500']; + if (criticalColor) { + applyCriticalBrandBundle(criticalColor, style); + skipRamp.critical = new Set(Object.keys(critical)); + } + } + const caution = brandPalette.caution; + if (caution && Object.keys(caution).length === 1) { + const cautionColor = caution['400'] ?? caution['500']; + if (cautionColor) { + applyCautionBrandBundle(cautionColor, style); + skipRamp.caution = new Set(Object.keys(caution)); + } + } + if (brandPalette.positive?.['600'] && Object.keys(brandPalette.positive).length === 1) { + applyPositiveBrandBundle(brandPalette.positive['600'], style); + skipRamp.positive = new Set(['600']); + } + } - if (primaryOnly && primaryRamp?.['600']) { - applyPrimaryBrandBundle(primaryRamp['600'], style); - } else if (theme.brand) { + if (brandPalette) { (['primary', 'critical', 'caution', 'positive', 'neutral', 'action'] as const).forEach( color => { - writeNumericalBrandRamp(color, theme.brand?.[color], style); + writeNumericalBrandRamp(color, brandPalette[color], style, {skipKeys: skipRamp[color]}); } ); } @@ -420,9 +531,19 @@ export function writeBrandScopeSemantic( theme: PartialEmotionCanvasTheme, style: React.CSSProperties ) { - const rawMain = theme.canvas?.palette?.primary?.main; - if (rawMain) { - applyPrimaryBrandBundle(rawMain, style); + const palette = theme.canvas?.palette; + + if (palette?.primary?.main) { + applyPrimaryBrandBundle(palette.primary.main, style); + } + if (palette?.error?.main) { + applyCriticalBrandBundle(palette.error.main, style); + } + if (palette?.alert?.main) { + applyCautionBrandBundle(palette.alert.main, style); + } + if (palette?.success?.main) { + applyPositiveBrandBundle(palette.success.main, style); } writeIndependentBrandTokens(theme, style); } diff --git a/modules/react/common/lib/theming/sanaTheme.ts b/modules/react/common/lib/theming/sanaTheme.ts index b92f9f1772..f2f17a1512 100644 --- a/modules/react/common/lib/theming/sanaTheme.ts +++ b/modules/react/common/lib/theming/sanaTheme.ts @@ -15,13 +15,23 @@ * | `sanaCanvasNumericalTheme` | Numerical `brand` shape for popup forwarding | * | `sanaCanvasProviderTheme` | Same — pass to root `CanvasProvider` with global Sana CSS | */ -import {brand} from '@workday/canvas-tokens-web'; +import {base, brand} from '@workday/canvas-tokens-web'; import type {CanvasNumericalBrandTheme} from './types'; /** Reference a canvas-tokens CSS variable (resolves under `[data-theme="sana-canvas"]`). */ const varRef = (token: string) => `var(${token})`; +/** + * Sana extends the neutral ramp with steps not yet exported from canvas-tokens-web JS. + * Defined in `@workday/canvas-tokens-web/css/sana/_variables.css`. + */ +const sanaBrandNeutral = { + '150': '--cnvs-brand-neutral-150', + '850': '--cnvs-brand-neutral-850', + A150: '--cnvs-brand-neutral-a150', +} as const; + /** * Sana Canvas brand tokens for scoped `CanvasProvider` / popup forwarding. * Values are `var()` references to Sana brand variables — not merged from `defaultCanvasTheme`. @@ -33,7 +43,7 @@ export const sanaCanvasNumericalTheme: CanvasNumericalBrandTheme = { base: varRef(brand.neutral975), dark: varRef(brand.neutral950), darkest: varRef(brand.neutral900), - accent: varRef(brand.neutral0), + accent: varRef(base.neutral0), lightest: varRef(brand.neutral25), lighter: varRef(brand.neutral50), light: varRef(brand.neutral200), @@ -42,7 +52,7 @@ export const sanaCanvasNumericalTheme: CanvasNumericalBrandTheme = { '25': varRef(brand.neutral25), '50': varRef(brand.neutral50), '100': varRef(brand.neutral100), - '150': varRef(brand.neutral150), + '150': varRef(sanaBrandNeutral['150']), '200': varRef(brand.neutral200), '300': varRef(brand.neutral300), '400': varRef(brand.neutral400), @@ -50,14 +60,14 @@ export const sanaCanvasNumericalTheme: CanvasNumericalBrandTheme = { '600': varRef(brand.neutral600), '700': varRef(brand.neutral700), '800': varRef(brand.neutral800), - '850': varRef(brand.neutral850), + '850': varRef(sanaBrandNeutral['850']), '900': varRef(brand.neutral900), '950': varRef(brand.neutral950), '975': varRef(brand.neutral975), A25: varRef(brand.neutralA25), A50: varRef(brand.neutralA50), A100: varRef(brand.neutralA100), - A150: varRef(brand.neutralA150), + A150: varRef(sanaBrandNeutral.A150), A200: varRef(brand.neutralA200), }, primary: { diff --git a/modules/react/common/lib/theming/types.ts b/modules/react/common/lib/theming/types.ts index de8e827eb6..1091bcbf45 100644 --- a/modules/react/common/lib/theming/types.ts +++ b/modules/react/common/lib/theming/types.ts @@ -297,6 +297,7 @@ export type CanvasBrandRamp = Partial< | '25' | '50' | '100' + | '150' | '200' | '300' | '400' @@ -304,17 +305,27 @@ export type CanvasBrandRamp = Partial< | '600' | '700' | '800' + | '850' | '900' | '950' | '975' | 'A25' | 'A50' | 'A100' + | 'A150' | 'A200', string > >; +/** Semantic keys for `brand.action.*` CSS variables (PrimaryButton, etc.). */ +export type CanvasActionBrandRamp = Partial< + Record< + 'base' | 'lightest' | 'lighter' | 'light' | 'dark' | 'darkest' | 'darker' | 'accent', + string + > +>; + /** * Controls how partial theme input is expanded. * @@ -378,7 +389,7 @@ export interface CanvasNumericalBrandTheme { * | `dark` / `darkest` | PrimaryButton hover / pressed | * | `accent` | PrimaryButton label color | */ - action?: CanvasBrandRamp; + action?: CanvasActionBrandRamp; /** * Critical/error ramp (`--cnvs-brand-critical-*`). @@ -537,7 +548,8 @@ export function resolveThemingScope(theme: CanvasProviderTheme | undefined): Can continue; } for (const key of Object.keys(colorPalette)) { - if (color === 'primary' && key === 'main') { + // `main` alone uses brand-scope bundles for every semantic palette color. + if (key === 'main') { continue; } if (EXTENDED_RAMP_KEYS.has(key)) { diff --git a/modules/react/common/spec/brandScope.spec.ts b/modules/react/common/spec/brandScope.spec.ts index d7fefb18a0..03eb6ee3c5 100644 --- a/modules/react/common/spec/brandScope.spec.ts +++ b/modules/react/common/spec/brandScope.spec.ts @@ -1,6 +1,13 @@ import {brand, system} from '@workday/canvas-tokens-web'; -import {applyPrimaryBrandBundle, writeIndependentBrandTokens} from '../lib/theming/brandScope'; +import { + applyCautionBrandBundle, + applyCriticalBrandBundle, + applyPrimaryBrandBundle, + writeBrandScopeSemantic, + writeIndependentBrandTokens, + writeNumericalTheme, +} from '../lib/theming/brandScope'; describe('applyPrimaryBrandBundle', () => { it('writes button and selected tokens but not focus', () => { @@ -10,8 +17,9 @@ describe('applyPrimaryBrandBundle', () => { expect(style[brand.action.base as string]).toBe('red'); expect(style[brand.primary600 as string]).toBe('red'); expect(style[system.color.brand.accent.primary as string]).toBe('red'); - expect(style[system.color.brand.fg.selected as string]).toBeDefined(); - expect(style[system.color.brand.surface.selected as string]).toBeDefined(); + expect(style[system.color.brand.fg.selected as string]).toContain('color-mix'); + expect(style[system.color.brand.fg.selected as string]).not.toBe(`var(${brand.primary700})`); + expect(style[system.color.brand.surface.selected as string]).toContain('color-mix'); expect(style[system.color.brand.focus.primary as string]).toBeUndefined(); expect(style[system.color.brand.border.primary as string]).toBeUndefined(); }); @@ -29,3 +37,73 @@ describe('writeIndependentBrandTokens', () => { expect(style[system.color.brand.border.primary as string]).toBe('teal'); }); }); + +describe('applyCriticalBrandBundle', () => { + it('writes TextInput error border and focus tokens', () => { + const style: Record = {}; + applyCriticalBrandBundle('crimson', style); + + expect(style[brand.error.base as string]).toBe('crimson'); + expect(style[system.color.brand.border.critical as string]).toBe('crimson'); + expect(style[system.color.brand.focus.critical as string]).toBe('crimson'); + }); +}); + +describe('applyCautionBrandBundle', () => { + it('writes TextInput caution border and inner focus tokens', () => { + const style: Record = {}; + applyCautionBrandBundle('coral', style); + + expect(style[brand.alert.base as string]).toBe('coral'); + expect(style[system.color.brand.border.caution as string]).toBe('coral'); + expect(style[system.color.brand.focus.caution?.inner as string]).toBe('coral'); + }); +}); + +describe('writeBrandScopeSemantic', () => { + it('applies error and alert bundles from customColorTheme-style input', () => { + const style: Record = {}; + writeBrandScopeSemantic( + { + canvas: { + palette: { + primary: {main: 'purple'}, + error: {main: 'crimson'}, + alert: {main: 'coral'}, + }, + }, + }, + style + ); + + expect(style[brand.action.base as string]).toBe('purple'); + expect(style[system.color.brand.border.critical as string]).toBe('crimson'); + expect(style[system.color.brand.border.caution as string]).toBe('coral'); + }); +}); + +describe('writeNumericalTheme', () => { + it('applies per-family bundles for multi-ramp numerical input', () => { + const style: Record = {}; + writeNumericalTheme( + { + brand: { + primary: {'600': 'purple', '500': 'turquoise'}, + action: {base: 'purple', accent: 'turquoise'}, + critical: {'600': 'crimson'}, + caution: {'400': 'coral'}, + positive: {'600': 'darkolivegreen'}, + }, + }, + style, + 'brand' + ); + + expect(style[brand.action.base as string]).toBe('purple'); + expect(style[brand.action.accent as string]).toBe('turquoise'); + expect(style[system.color.brand.border.critical as string]).toBe('crimson'); + expect(style[system.color.brand.border.caution as string]).toBe('coral'); + expect(style[system.color.brand.accent.positive as string]).toBe('darkolivegreen'); + expect(style[system.color.brand.focus.primary as string]).toBe('turquoise'); + }); +}); diff --git a/modules/react/common/spec/sanaTheme.spec.ts b/modules/react/common/spec/sanaTheme.spec.ts index 1079404096..7166c5ccf5 100644 --- a/modules/react/common/spec/sanaTheme.spec.ts +++ b/modules/react/common/spec/sanaTheme.spec.ts @@ -1,4 +1,4 @@ -import {brand} from '@workday/canvas-tokens-web'; +import {base, brand} from '@workday/canvas-tokens-web'; import {canvasThemeToCssVars} from '../lib/CanvasProvider'; import {defaultCanvasTheme} from '../lib/theming'; @@ -8,6 +8,7 @@ describe('sanaCanvasNumericalTheme', () => { it('references Sana brand CSS variables instead of defaultCanvasTheme literals', () => { expect(sanaCanvasNumericalTheme.brand?.neutral?.['600']).toBe(`var(${brand.neutral600})`); expect(sanaCanvasNumericalTheme.brand?.action?.base).toBe(`var(${brand.neutral975})`); + expect(sanaCanvasNumericalTheme.brand?.action?.accent).toBe(`var(${base.neutral0})`); expect(sanaCanvasNumericalTheme.brand?.neutral?.['600']).not.toBe( defaultCanvasTheme.palette.neutral.main ); @@ -19,4 +20,11 @@ describe('sanaCanvasNumericalTheme', () => { expect(style[brand.neutral600 as any]).toBe(`var(${brand.neutral600})`); expect(style[brand.action.base as any]).toBe(`var(${brand.neutral975})`); }); + + it('writes Sana extended neutral ramp keys', () => { + const {style} = canvasThemeToCssVars(sanaCanvasProviderTheme, {}); + expect(style['--cnvs-brand-neutral-150' as any]).toBe('var(--cnvs-brand-neutral-150)'); + expect(style['--cnvs-brand-neutral-850' as any]).toBe('var(--cnvs-brand-neutral-850)'); + expect(style['--cnvs-brand-neutral-a150' as any]).toBe('var(--cnvs-brand-neutral-a150)'); + }); }); diff --git a/modules/react/common/spec/theming-types.spec.ts b/modules/react/common/spec/theming-types.spec.ts index bd26dd6920..19d0ef0174 100644 --- a/modules/react/common/spec/theming-types.spec.ts +++ b/modules/react/common/spec/theming-types.spec.ts @@ -37,4 +37,12 @@ describe('resolveThemingScope', () => { 'brand' ); }); + + it('error and alert main-only input stays on brand scope', () => { + expect( + resolveThemingScope({ + canvas: {palette: {error: {main: 'crimson'}, alert: {main: 'coral'}}}, + }) + ).toBe('brand'); + }); }); diff --git a/modules/react/common/spec/useCanvasThemeToCssVars.spec.tsx b/modules/react/common/spec/useCanvasThemeToCssVars.spec.tsx index 6cbe0edcaa..84c70eec41 100644 --- a/modules/react/common/spec/useCanvasThemeToCssVars.spec.tsx +++ b/modules/react/common/spec/useCanvasThemeToCssVars.spec.tsx @@ -41,4 +41,23 @@ describe('useCanvasThemeToCssVars — brand scope', () => { expect(result.current.style[brand.action.base as any]).toBe('red'); expect(result.current.style[system.color.brand.focus.primary as any]).toBe('teal'); }); + + it('writes error and alert tokens for brand-scope error/alert main', () => { + const {result} = renderHook(() => + useCanvasThemeToCssVars( + { + canvas: { + palette: { + error: {main: 'crimson'}, + alert: {main: 'coral'}, + }, + }, + }, + {} + ) + ); + expect(result.current.style[system.color.brand.border.critical as any]).toBe('crimson'); + expect(result.current.style[system.color.brand.border.caution as any]).toBe('coral'); + expect(result.current.style[system.color.brand.focus.caution?.inner as any]).toBe('coral'); + }); }); diff --git a/modules/react/package.json b/modules/react/package.json index e19c8b5c34..978ec3f131 100644 --- a/modules/react/package.json +++ b/modules/react/package.json @@ -56,7 +56,7 @@ "@workday/canvas-kit-popup-stack": "^15.1.4", "@workday/canvas-kit-styling": "^15.1.4", "@workday/canvas-system-icons-web": "5.0.1", - "@workday/canvas-tokens-web": "4.4.0-beta.11", + "@workday/canvas-tokens-web": "4.4.0", "@workday/design-assets-types": "^0.3.0", "chroma-js": "^2.2.0", "csstype": "^3.0.2", diff --git a/modules/react/popup/lib/hooks/usePopupStack.ts b/modules/react/popup/lib/hooks/usePopupStack.ts index 1e6e64f89e..b6b2259cc2 100644 --- a/modules/react/popup/lib/hooks/usePopupStack.ts +++ b/modules/react/popup/lib/hooks/usePopupStack.ts @@ -1,13 +1,7 @@ -import {Theme, ThemeContext} from '@emotion/react'; import React from 'react'; import {PopupStack} from '@workday/canvas-kit-popup-stack'; -import { - CanvasProviderTheme, - canvasThemeToCssVars, - isElementRTL, - useLocalRef, -} from '@workday/canvas-kit-react/common'; +import {CanvasBrandStyleContext, isElementRTL, useLocalRef} from '@workday/canvas-kit-react/common'; /** * **Note:** If you're using {@link Popper}, you do not need to use this hook directly. @@ -57,8 +51,8 @@ export const usePopupStack = ( ): React.RefObject => { const {elementRef, localRef} = useLocalRef(ref); - const theme = React.useContext(ThemeContext as React.Context); - const {style} = canvasThemeToCssVars(theme as CanvasProviderTheme, {}); + // Read brand style from the context provided by CanvasProvider + const style = React.useContext(CanvasBrandStyleContext); const firstLoadRef = React.useRef(true); // React 19 can call a useState more than once, so we need to track if we've already created a container // useState function input ensures we only create a container once. diff --git a/modules/react/popup/spec/usePopupStack.spec.tsx b/modules/react/popup/spec/usePopupStack.spec.tsx new file mode 100644 index 0000000000..8523d5a878 --- /dev/null +++ b/modules/react/popup/spec/usePopupStack.spec.tsx @@ -0,0 +1,207 @@ +import {renderHook, waitFor} from '@testing-library/react'; +import React from 'react'; + +import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; + +import {usePopupStack} from '../lib/hooks/usePopupStack'; + +describe('usePopupStack', () => { + afterEach(() => { + // Clean up any popup containers created during tests + document.querySelectorAll('.popup-stack').forEach(el => el.remove()); + }); + + describe('theme forwarding', () => { + it('should forward numerical theme CSS variables to popup container', async () => { + const numericalTheme = { + brand: { + primary: { + '600': '#123456', + '700': '#234567', + }, + }, + }; + + const wrapper = ({children}: {children: React.ReactNode}) => ( + {children} + ); + + const {result} = renderHook(() => usePopupStack(), {wrapper}); + + const container = result.current.current; + expect(container).toBeTruthy(); + + // Wait for the effect to apply styles + await waitFor(() => { + // Check that the container has the correct CSS variables + const styles = container?.style; + if (styles) { + // The numerical theme should set brand variables + // Numerical themes use the key names directly (600, 700, etc.) + const primary600 = styles.getPropertyValue('--cnvs-brand-primary-600'); + const primary700 = styles.getPropertyValue('--cnvs-brand-primary-700'); + expect(primary600).toBe('#123456'); + expect(primary700).toBe('#234567'); + } + }); + }); + + it('should forward sanaCanvasProviderTheme CSS variables to popup container', async () => { + const wrapper = ({children}: {children: React.ReactNode}) => ( + {children} + ); + + const {result} = renderHook(() => usePopupStack(), {wrapper}); + + const container = result.current.current; + expect(container).toBeTruthy(); + + // Wait for the effect to apply styles + await waitFor(() => { + // Check that the container has the correct CSS variables + const styles = container?.style; + if (styles) { + // The Sana theme should set brand variables + // Sana theme uses CSS variable references (var(--cnvs-brand-primary-600)) + const primary600 = styles.getPropertyValue('--cnvs-brand-primary-600'); + // Check that the value is a CSS variable reference + expect(primary600).toContain('var(--cnvs-brand-primary-600)'); + } + }); + }); + + it('should forward legacy palette theme CSS variables to popup container', async () => { + const legacyTheme = { + canvas: { + palette: { + primary: { + main: '#FF00FF', + }, + }, + }, + }; + + const wrapper = ({children}: {children: React.ReactNode}) => ( + {children} + ); + + const {result} = renderHook(() => usePopupStack(), {wrapper}); + + const container = result.current.current; + expect(container).toBeTruthy(); + + // Wait for the effect to apply styles + await waitFor(() => { + // Check that the container has the correct CSS variables + const styles = container?.style; + if (styles) { + // The legacy theme should set brand variables based on the primary color + // Legacy themes with brand scope set the primary-base variable + const primaryBase = styles.getPropertyValue('--cnvs-brand-primary-base'); + expect(primaryBase).toBe('#FF00FF'); + } + }); + }); + + it('should not set CSS variables when no theme is provided', () => { + const wrapper = ({children}: {children: React.ReactNode}) => ( + {children} + ); + + const {result} = renderHook(() => usePopupStack(), {wrapper}); + + const container = result.current.current; + expect(container).toBeTruthy(); + + // Check that no brand CSS variables are set + const styles = container?.style; + if (styles) { + // When no theme is provided, no brand variables should be forced + expect(styles.getPropertyValue('--cnvs-brand-primary-base')).toBe(''); + } + }); + + it('should merge styles from nested CanvasProviders', async () => { + const parentTheme = { + brand: { + primary: { + '600': '#111111', + }, + }, + }; + + const childTheme = { + brand: { + critical: { + '600': '#FF0000', + }, + }, + }; + + const wrapper = ({children}: {children: React.ReactNode}) => ( + + {children} + + ); + + const {result} = renderHook(() => usePopupStack(), {wrapper}); + + const container = result.current.current; + expect(container).toBeTruthy(); + + // Wait for the effect to apply styles + await waitFor(() => { + // Check that the container has variables from both themes + const styles = container?.style; + if (styles) { + // Should have both primary (from parent) and critical (from child) variables + // Numerical themes use the key names directly (600, etc.) + const primary600 = styles.getPropertyValue('--cnvs-brand-primary-600'); + const critical600 = styles.getPropertyValue('--cnvs-brand-critical-600'); + expect(primary600).toBe('#111111'); + expect(critical600).toBe('#FF0000'); + } + }); + }); + + it('should not force classic defaults when using numerical themes', () => { + const numericalTheme = { + brand: { + primary: { + '600': '#987654', + }, + }, + }; + + const wrapper = ({children}: {children: React.ReactNode}) => ( + {children} + ); + + const {result} = renderHook(() => usePopupStack(), {wrapper}); + + const container = result.current.current; + expect(container).toBeTruthy(); + + // Check that classic defaults are NOT forced onto the container + const styles = container?.style; + if (styles) { + // Should not have classic blue/red/amber overrides when not explicitly set + // Instead, should only have what the numerical theme specifies + const allProperties = Array.from({length: styles.length}, (_, i) => styles.item(i)).filter( + prop => prop.startsWith('--cnvs-') + ); + + // Check that we're not forcing all the classic variables + // The numerical theme only set primary, so we shouldn't have forced other colors + const hasUnrelatedColors = allProperties.some( + prop => + (prop.includes('--cnvs-base-blue') || + prop.includes('--cnvs-base-red') || + prop.includes('--cnvs-base-amber')) && + !prop.includes('primary') + ); + expect(hasUnrelatedColors).toBe(false); + } + }); + }); +}); diff --git a/modules/react/testing/lib/StaticStates.tsx b/modules/react/testing/lib/StaticStates.tsx index ed60772063..cafdd9e6f1 100644 --- a/modules/react/testing/lib/StaticStates.tsx +++ b/modules/react/testing/lib/StaticStates.tsx @@ -3,9 +3,11 @@ import * as React from 'react'; import { CanvasProvider, + CanvasProviderTheme, EmotionCanvasTheme, PartialEmotionCanvasTheme, StyleRewriteFn, + isNumericalTheme, useTheme, } from '@workday/canvas-kit-react/common'; import {CSSProperties} from '@workday/canvas-kit-react/tokens'; @@ -35,12 +37,14 @@ export const convertToStaticStates: StyleRewriteFn = obj => { export const StaticStates: React.FC< React.PropsWithChildren< { - theme?: PartialEmotionCanvasTheme; + theme?: CanvasProviderTheme; className?: React.HTMLAttributes['className']; } & React.HTMLAttributes > > = ({children, theme, className, ...elemProps}) => { - const localTheme: EmotionCanvasTheme & {_styleRewriteFn?: StyleRewriteFn} = useTheme(theme); + const localTheme: EmotionCanvasTheme & {_styleRewriteFn?: StyleRewriteFn} = useTheme( + theme && !isNumericalTheme(theme) ? (theme as PartialEmotionCanvasTheme) : undefined + ); localTheme._styleRewriteFn = convertToStaticStates; return ( diff --git a/modules/react/text-area/stories/visualTesting.stories.tsx b/modules/react/text-area/stories/visualTesting.stories.tsx index f3a3144030..8b5a925c57 100644 --- a/modules/react/text-area/stories/visualTesting.stories.tsx +++ b/modules/react/text-area/stories/visualTesting.stories.tsx @@ -1,5 +1,4 @@ -import * as React from 'react'; - +import {CanvasProviderTheme} from '@workday/canvas-kit-react/common'; import { ComponentStatesTable, StaticStates, @@ -7,7 +6,7 @@ import { } from '@workday/canvas-kit-react/testing'; import {TextArea} from '@workday/canvas-kit-react/text-area'; -import {customColorTheme} from '../../../../utils/storybook'; +import {customNumericalTheme, toCanvasProviderTheme} from '../../../../utils/storybook'; export default { title: 'Testing/Inputs/Text Area', @@ -19,8 +18,8 @@ export default { }, }; -export const TextAreaStates = () => ( - +export const TextAreaStates = ({theme}: {theme?: CanvasProviderTheme} = {}) => ( + ( ); -export const TextAreaThemedStates = () => ; -TextAreaThemedStates.parameters = { - canvasProviderDecorator: { - theme: customColorTheme, - }, -}; +export const TextAreaThemedStates = () => ; diff --git a/modules/react/text-input/stories/visualTesting.stories.tsx b/modules/react/text-input/stories/visualTesting.stories.tsx index c39826c549..3ac084a5c9 100644 --- a/modules/react/text-input/stories/visualTesting.stories.tsx +++ b/modules/react/text-input/stories/visualTesting.stories.tsx @@ -1,5 +1,5 @@ import {TertiaryButton} from '@workday/canvas-kit-react/button'; -import {CanvasProvider} from '@workday/canvas-kit-react/common'; +import {CanvasProvider, CanvasProviderTheme} from '@workday/canvas-kit-react/common'; import {SystemIcon} from '@workday/canvas-kit-react/icon'; import { ComponentStatesTable, @@ -11,7 +11,7 @@ import {px2rem} from '@workday/canvas-kit-styling'; import {searchIcon, xSmallIcon} from '@workday/canvas-system-icons-web'; import {system} from '@workday/canvas-tokens-web'; -import {customColorTheme} from '../../../../utils/storybook'; +import {customColorTheme, toCanvasProviderTheme} from '../../../../utils/storybook'; export default { title: 'Testing/Inputs/Text Input', @@ -23,8 +23,8 @@ export default { }, }; -export const TextInputStates = () => ( - +export const TextInputStates = ({theme}: {theme?: CanvasProviderTheme} = {}) => ( + ( ); -export const TextInputThemedStates = () => ; -TextInputThemedStates.parameters = { - canvasProviderDecorator: { - theme: customColorTheme, - }, -}; +export const TextInputThemedStates = () => ; export const InputGroupStates = () => ( diff --git a/modules/styling-transform/package.json b/modules/styling-transform/package.json index 959c90f2cc..ec30c0bf9e 100644 --- a/modules/styling-transform/package.json +++ b/modules/styling-transform/package.json @@ -46,7 +46,7 @@ "dependencies": { "@emotion/serialize": "^1.0.2", "@workday/canvas-kit-styling": "^15.1.4", - "@workday/canvas-tokens-web": "4.4.0-beta.11", + "@workday/canvas-tokens-web": "4.4.0", "stylis": "4.3.6", "ts-node": "^10.9.1", "typescript": "5.0" diff --git a/modules/styling-transform/spec/utils/handleColorSpace.spec.ts b/modules/styling-transform/spec/utils/handleColorSpace.spec.ts new file mode 100644 index 0000000000..2346cca40e --- /dev/null +++ b/modules/styling-transform/spec/utils/handleColorSpace.spec.ts @@ -0,0 +1,83 @@ +import ts from 'typescript'; + +import {colorSpace} from '@workday/canvas-kit-styling'; +import { + createProgramFromSource, + findNodes, + withDefaultContext, +} from '@workday/canvas-kit-styling-transform/testing'; + +import {handleColorSpace} from '../../lib/utils/handleColorSpace'; + +describe('handleColorSpace', () => { + it('should handle colorSpace.darken', () => { + const program = createProgramFromSource(` + colorSpace.darken({ + color: '--cnvs-sys-color-surface-alt-default', + fallback: '--cnvs-sys-color-bg-alt-soft', + mixinColor: '--cnvs-sys-color-surface-overlay-mixin', + mixinValue: '--cnvs-sys-opacity-surface-hover', + }) + `); + + const sourceFile = program.getSourceFile('test.ts')!; + const node = findNodes(sourceFile, '', ts.isCallExpression)![0]; + + const result = handleColorSpace(node, withDefaultContext(program.getTypeChecker())); + + expect(result).toEqual( + colorSpace.darken({ + color: '--cnvs-sys-color-surface-alt-default', + fallback: '--cnvs-sys-color-bg-alt-soft', + mixinColor: '--cnvs-sys-color-surface-overlay-mixin', + mixinValue: '--cnvs-sys-opacity-surface-hover', + }) + ); + }); + + it('should handle colorSpace.hover', () => { + const program = createProgramFromSource(` + colorSpace.hover({ + color: '--cnvs-sys-color-brand-accent-primary', + fallback: '--cnvs-brand-primary-base', + colorType: 'accent', + }) + `); + + const sourceFile = program.getSourceFile('test.ts')!; + const node = findNodes(sourceFile, '', ts.isCallExpression)![0]; + + const result = handleColorSpace(node, withDefaultContext(program.getTypeChecker())); + + expect(result).toEqual( + colorSpace.hover({ + color: '--cnvs-sys-color-brand-accent-primary', + fallback: '--cnvs-brand-primary-base', + colorType: 'accent', + }) + ); + }); + + it('should handle colorSpace.pressed', () => { + const program = createProgramFromSource(` + colorSpace.pressed({ + color: '--cnvs-sys-color-brand-accent-primary', + fallback: '--cnvs-brand-primary-base', + colorType: 'accent', + }) + `); + + const sourceFile = program.getSourceFile('test.ts')!; + const node = findNodes(sourceFile, '', ts.isCallExpression)![0]; + + const result = handleColorSpace(node, withDefaultContext(program.getTypeChecker())); + + expect(result).toEqual( + colorSpace.pressed({ + color: '--cnvs-sys-color-brand-accent-primary', + fallback: '--cnvs-brand-primary-base', + colorType: 'accent', + }) + ); + }); +}); diff --git a/modules/styling/package.json b/modules/styling/package.json index ebcd77fe08..139398b60c 100644 --- a/modules/styling/package.json +++ b/modules/styling/package.json @@ -54,7 +54,7 @@ "@emotion/serialize": "^1.0.2", "@emotion/styled": "^11.6.0", "@workday/canvas-system-icons-web": "5.0.1", - "@workday/canvas-tokens-web": "4.4.0-beta.11", + "@workday/canvas-tokens-web": "4.4.0", "typescript": "5.0" } } diff --git a/package.json b/package.json index c82e06d254..79f20d4c87 100644 --- a/package.json +++ b/package.json @@ -139,7 +139,7 @@ "@workday/canvas-applet-icons-web": "^2.0.15", "@workday/canvas-expressive-icons-web": "1.0.2", "@workday/canvas-system-icons-web": "5.0.1", - "@workday/canvas-tokens-web": "4.4.0-beta.11", + "@workday/canvas-tokens-web": "4.4.0", "resolutions": { "ansi-regex": "3.0.1", "braces": "3.0.3", diff --git a/utils/storybook/customThemes.ts b/utils/storybook/customThemes.ts index 15600ad919..146560f7c0 100644 --- a/utils/storybook/customThemes.ts +++ b/utils/storybook/customThemes.ts @@ -1,6 +1,27 @@ -import {CanvasNumericalBrandTheme, PartialCanvasTheme} from '@workday/canvas-kit-react/common'; +import { + CanvasNumericalBrandTheme, + CanvasProviderTheme, + PartialCanvasTheme, + isNumericalTheme, +} from '@workday/canvas-kit-react/common'; import {base} from '@workday/canvas-tokens-web'; +/** Wrap legacy palette themes; pass numerical `brand` themes through unchanged. */ +export function toCanvasProviderTheme( + theme?: PartialCanvasTheme | CanvasNumericalBrandTheme | CanvasProviderTheme +): CanvasProviderTheme | undefined { + if (!theme) { + return undefined; + } + if (isNumericalTheme(theme)) { + return theme; + } + if ('canvas' in theme) { + return theme; + } + return {canvas: theme as PartialCanvasTheme}; +} + export const customColorTheme: PartialCanvasTheme = { palette: { primary: { @@ -30,6 +51,43 @@ export const brandScopePrimaryOnly: CanvasNumericalBrandTheme = { brand: {primary: {'600': base.magenta600}}, }; +/** + * Numerical `brand` preset — mirrors {@link customColorTheme} using the v16 theming API. + * + * | Key | Maps to | Consumers | + * |-----|---------|-----------| + * | `primary['600']` + `action` | purple / turquoise label | PrimaryButton, accents | + * | `primary['500']` | turquoise | Focus rings | + * | `critical['600']` | crimson | TextInput error, DeleteButton | + * | `caution['400']` | coral | TextInput caution | + * | `positive['600']` | darkolivegreen | Checkbox, Radio checked | + * | `neutral['600']` | gray | Neutral brand text | + */ +export const customNumericalTheme: CanvasNumericalBrandTheme = { + brand: { + primary: { + '600': 'purple', + '500': 'turquoise', + }, + critical: { + '600': 'crimson', + }, + caution: { + '400': 'coral', + }, + positive: { + '600': 'darkolivegreen', + }, + neutral: { + '600': 'gray', + }, + action: { + base: 'purple', + accent: 'turquoise', + }, + }, +}; + /** Primary + independent focus color */ export const primaryWithFocus = { canvas: { diff --git a/utils/storybook/index.ts b/utils/storybook/index.ts index 83d96d169f..7b7451f1f3 100644 --- a/utils/storybook/index.ts +++ b/utils/storybook/index.ts @@ -4,7 +4,7 @@ export { useControlledValue, useControlledCheck, } from './ControlledComponentWrapper'; -export {customColorTheme} from './customThemes'; +export {customColorTheme, customNumericalTheme, toCanvasProviderTheme} from './customThemes'; export {withSnapshotsEnabled} from './withSnapshotsEnabled'; export {default as CanvasProviderDecorator} from './CanvasProviderDecorator'; export {PopperController, customViewport} from './PopperController'; diff --git a/yarn.lock b/yarn.lock index 93d11ad92b..f2571b040a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4682,10 +4682,10 @@ resolved "https://registry.yarnpkg.com/@workday/canvas-system-icons-web/-/canvas-system-icons-web-5.0.1.tgz#683368affaf5031bb1400efbbcfc1a6c2062dd0a" integrity sha512-MnZjqG7RE3kDfD40c+VJSxoH1972Yd1bngdun7eBGSdk7aOGqaAPwvOpttx/mfvf+ovs79jyh/ep1X8u8LPlSw== -"@workday/canvas-tokens-web@4.4.0-beta.11": - version "4.4.0-beta.11" - resolved "https://registry.yarnpkg.com/@workday/canvas-tokens-web/-/canvas-tokens-web-4.4.0-beta.11.tgz#c8612abd7b5a7aa8397b6f820bf86b214fc6d107" - integrity sha512-/knOJf8VSM5KGg3++Rdzr/MqG5JdFWrmvnfw0twP4ERApXMPmKKoPAR84TeMPqjoBW3ZX3dOsUTTIkqoj5Kizg== +"@workday/canvas-tokens-web@4.4.0": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@workday/canvas-tokens-web/-/canvas-tokens-web-4.4.0.tgz#047b622ef0e0b5c142321ba3b7a2ea5039e91f7e" + integrity sha512-VSEX7LpVpFlzmqZTHWB95PEL1VLCg4pjqyu4VxvOXgMXICJioJhin99TspTFZZqBKy8PTt9aN3rXZg+Jvn+aPw== "@workday/design-assets-types@0.2.8": version "0.2.8" From 05c92ec6f508d0d25867d55ac85ece77adf7e4f4 Mon Sep 17 00:00:00 2001 From: "manuel.carrera" Date: Fri, 31 Jul 2026 09:52:53 -0600 Subject: [PATCH 06/19] docs: Add design spec for making sanaCanvasProviderTheme optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When data-theme="sana-canvas" is set globally, teams don't need to pass sanaCanvasProviderTheme to CanvasProvider since CSS variables naturally cascade to popup containers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../2026-07-31-sana-theme-optional-design.md | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 specs/2026-07-31-sana-theme-optional-design.md diff --git a/specs/2026-07-31-sana-theme-optional-design.md b/specs/2026-07-31-sana-theme-optional-design.md new file mode 100644 index 0000000000..6d1bc76879 --- /dev/null +++ b/specs/2026-07-31-sana-theme-optional-design.md @@ -0,0 +1,132 @@ +# Making sanaCanvasProviderTheme Optional - Design Spec + +## Overview + +Currently, teams using the Sana Canvas theme must pass `sanaCanvasProviderTheme` to their root `CanvasProvider` to ensure popups inherit the correct theme. Since CSS variables naturally cascade through the DOM when `data-theme="sana-canvas"` is set on ``, this JavaScript forwarding is redundant for most use cases. + +## Goals + +- Simplify the default Sana Canvas theme setup +- Remove unnecessary configuration for teams +- Maintain backward compatibility +- Provide clear guidance on when the theme prop is still needed + +## Design + +### Simplified Default Setup + +Teams using Sana Canvas globally will use a simpler setup: + +```tsx +// New recommended setup - no theme prop needed +import {CanvasProvider} from '@workday/canvas-kit-react/common'; + + + + +``` + +The CSS setup remains unchanged: +```css +/* index.css */ +@import '@workday/canvas-tokens-web/css/sana/_variables.css'; +``` + +```html + +``` + +### When Theme Prop Is Still Needed + +The `sanaCanvasProviderTheme` remains available for specific scenarios: + +1. **Scoped Theming**: When a section needs different branding + ```tsx + + + + ``` + +2. **Testing**: When global CSS isn't loaded in test environments + +3. **Legacy Migration**: Applications transitioning to Sana incrementally + +4. **Edge Cases**: Custom popup containers rendered outside normal document flow + +### Console Warning + +Add a development-only warning when `sanaCanvasProviderTheme` is used unnecessarily: + +```typescript +if (process.env.NODE_ENV !== 'production') { + if (theme === sanaCanvasProviderTheme && + document.documentElement.getAttribute('data-theme') === 'sana-canvas') { + console.warn( + 'Canvas Kit: You are passing sanaCanvasProviderTheme to CanvasProvider but ' + + 'data-theme="sana-canvas" is already set globally. The theme prop is not needed ' + + 'in this case and can be removed for simpler setup.' + ); + } +} +``` + +## Implementation Plan + +### 1. Documentation Updates + +Update the following files: +- `/modules/docs/llm/theming.md` - Remove requirement for sanaCanvasProviderTheme in global setup +- `/modules/react/common/lib/theming/README.md` - Clarify optional nature +- `/modules/react/common/lib/theming/sanaTheme.ts` - Update JSDoc comments +- `/modules/react/common/stories/mdx/Theming.mdx` - Show simplified setup as default + +### 2. Code Changes + +- Add console warning in CanvasProvider when theme is unnecessary +- Update TypeScript types/comments to indicate optional nature +- Ensure popup components properly inherit CSS variables without theme prop + +### 3. Migration Guide + +Add to v16 upgrade guide: +```markdown +## Simplified Sana Canvas Setup + +If you're using Sana Canvas globally with `data-theme="sana-canvas"`, you no longer need to pass +`sanaCanvasProviderTheme` to CanvasProvider: + +**Before:** +```tsx + + + +``` + +**After:** +```tsx + + + +``` + +The theme prop is now only needed for scoped theming scenarios. +``` + +### 4. Testing + +- Verify popups inherit Sana theme without provider theme prop +- Test scoped theming still works with theme prop +- Ensure console warning appears only when appropriate +- Confirm backward compatibility with existing implementations + +## Success Criteria + +- Teams can use Sana Canvas theme without any theme prop on CanvasProvider +- Popups (menus, modals, selects) correctly inherit global theme +- Documentation clearly explains when theme prop is needed +- No breaking changes for existing implementations +- Console warning helps teams simplify their setup + +## Timeline + +This is a non-breaking enhancement that simplifies the API. Implementation involves primarily documentation updates and adding a helpful console warning. \ No newline at end of file From a5a1cfb6234c6d9fd5885c25dad129583435a040 Mon Sep 17 00:00:00 2001 From: "manuel.carrera" Date: Fri, 31 Jul 2026 10:02:36 -0600 Subject: [PATCH 07/19] docs: Clarify sanaCanvasProviderTheme is optional with global Sana CSS Popups naturally inherit CSS variables from [data-theme="sana-canvas"], making the theme prop unnecessary for most use cases. --- modules/docs/llm/theming.md | 13 ++++++++----- modules/react/common/stories/mdx/Theming.mdx | 13 ++++++++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/modules/docs/llm/theming.md b/modules/docs/llm/theming.md index 20a67c9b72..0260713a39 100644 --- a/modules/docs/llm/theming.md +++ b/modules/docs/llm/theming.md @@ -97,18 +97,21 @@ import {base} from '@workday/canvas-tokens-web'; ``` -Popups (menus, selects, modals) portal to `document.body`. When `data-theme="sana-canvas"` is on -``, they inherit the global theme automatically. For scoped theming, pass -`sanaCanvasProviderTheme` at your root `CanvasProvider` so popups match: +Popups (menus, selects, modals) portal to `document.body` and inherit CSS variables from +`[data-theme="sana-canvas"]` automatically. In most cases, no theme prop is needed: ```tsx -import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; +import {CanvasProvider} from '@workday/canvas-kit-react/common'; - +// Simple setup - popups inherit global Sana theme + ``` +**Note:** The `sanaCanvasProviderTheme` export remains available for edge cases like testing +environments without global CSS or custom popup containers outside the normal document flow. + See **Features/Theming → Sana Canvas** in Storybook for a side-by-side comparison of global and scoped branding. diff --git a/modules/react/common/stories/mdx/Theming.mdx b/modules/react/common/stories/mdx/Theming.mdx index 5d20f01523..04d92ea338 100644 --- a/modules/react/common/stories/mdx/Theming.mdx +++ b/modules/react/common/stories/mdx/Theming.mdx @@ -102,18 +102,21 @@ import {base} from '@workday/canvas-tokens-web'; -Popups (menus, selects, modals) portal to `document.body`. When `data-theme="sana-canvas"` is on -``, they inherit the global theme automatically. For scoped theming, pass -`sanaCanvasProviderTheme` at your root `CanvasProvider` so popups match: +Popups (menus, selects, modals) portal to `document.body` and inherit CSS variables from +`[data-theme="sana-canvas"]` automatically. In most cases, no theme prop is needed: ```tsx -import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; +import {CanvasProvider} from '@workday/canvas-kit-react/common'; - +// Simple setup - popups inherit global Sana theme + ``` +**Note:** The `sanaCanvasProviderTheme` export remains available for edge cases like testing +environments without global CSS or custom popup containers outside the normal document flow. + See **Features/Theming → Sana Canvas** in Storybook for a side-by-side comparison of global and scoped branding. From 288dcd3ecdf0b89351a69345d65860c9d8f604e2 Mon Sep 17 00:00:00 2001 From: "manuel.carrera" Date: Fri, 31 Jul 2026 10:07:50 -0600 Subject: [PATCH 08/19] feat: Add console warning for unnecessary sanaCanvasProviderTheme usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Warns developers when they pass sanaCanvasProviderTheme to CanvasProvider but already have data-theme="sana-canvas" set globally, since CSS variables naturally cascade to popups in this case. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- modules/react/common/lib/CanvasProvider.tsx | 19 ++++++ .../react/common/spec/CanvasProvider.spec.tsx | 61 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 modules/react/common/spec/CanvasProvider.spec.tsx diff --git a/modules/react/common/lib/CanvasProvider.tsx b/modules/react/common/lib/CanvasProvider.tsx index 9b69f82636..c914c34158 100644 --- a/modules/react/common/lib/CanvasProvider.tsx +++ b/modules/react/common/lib/CanvasProvider.tsx @@ -21,6 +21,7 @@ import { writeNumericalTheme, writeSemanticTheme, } from './theming/brandScope'; +import {sanaCanvasProviderTheme} from './theming/sanaTheme'; /** * Context for providing brand CSS variables to popup containers. @@ -195,6 +196,24 @@ export const CanvasProvider = ({ themeScope, ...props }: CanvasProviderProps & React.HTMLAttributes) => { + // Add console warning for unnecessary sanaCanvasProviderTheme usage + React.useEffect(() => { + if (process.env.NODE_ENV !== 'production') { + if ( + theme === sanaCanvasProviderTheme && + typeof document !== 'undefined' && + document.documentElement.getAttribute('data-theme') === 'sana-canvas' + ) { + console.warn( + 'Canvas Kit: You are passing sanaCanvasProviderTheme to CanvasProvider but ' + + 'data-theme="sana-canvas" is already set globally. The theme prop is not needed ' + + 'in this case and can be removed for simpler setup. See: ' + + 'https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs' + ); + } + } + }, [theme]); + const {className, style, ...elemProps} = useCanvasThemeToCssVars(theme, props, themeScope); const cache = getCache(); const rest = {...elemProps, ...props}; diff --git a/modules/react/common/spec/CanvasProvider.spec.tsx b/modules/react/common/spec/CanvasProvider.spec.tsx new file mode 100644 index 0000000000..b77dc581a5 --- /dev/null +++ b/modules/react/common/spec/CanvasProvider.spec.tsx @@ -0,0 +1,61 @@ +import {render} from '@testing-library/react'; +import * as React from 'react'; + +import {CanvasProvider} from '../lib/CanvasProvider'; +import {sanaCanvasProviderTheme} from '../lib/theming/sanaTheme'; + +describe('CanvasProvider', () => { + describe('console warnings', () => { + it('should warn when sanaCanvasProviderTheme is used with global Sana theme', () => { + const consoleSpy = vi.spyOn(global.console, 'warn'); + + // Set data-theme on document + document.documentElement.setAttribute('data-theme', 'sana-canvas'); + + render( + +
Test
+
+ ); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('You are passing sanaCanvasProviderTheme to CanvasProvider but') + ); + + // Cleanup + document.documentElement.removeAttribute('data-theme'); + consoleSpy.mockRestore(); + }); + + it('should not warn when sanaCanvasProviderTheme is used without global Sana theme', () => { + const consoleSpy = vi.spyOn(global.console, 'warn'); + + render( + +
Test
+
+ ); + + expect(consoleSpy).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should not warn when using a different theme', () => { + const consoleSpy = vi.spyOn(global.console, 'warn'); + + document.documentElement.setAttribute('data-theme', 'sana-canvas'); + + render( + +
Test
+
+ ); + + expect(consoleSpy).not.toHaveBeenCalled(); + + document.documentElement.removeAttribute('data-theme'); + consoleSpy.mockRestore(); + }); + }); +}); From 14e08b810a57ac006fe93169a8263b42ab01a077 Mon Sep 17 00:00:00 2001 From: "manuel.carrera" Date: Fri, 31 Jul 2026 10:14:59 -0600 Subject: [PATCH 09/19] fix: Suppress console output in CanvasProvider test spies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added .mockImplementation(() => {}) to all console.warn spies to prevent actual warnings from being printed to stderr during test runs. This improves test hygiene and provides cleaner test output. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- modules/react/common/spec/CanvasProvider.spec.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/react/common/spec/CanvasProvider.spec.tsx b/modules/react/common/spec/CanvasProvider.spec.tsx index b77dc581a5..487feeb115 100644 --- a/modules/react/common/spec/CanvasProvider.spec.tsx +++ b/modules/react/common/spec/CanvasProvider.spec.tsx @@ -7,7 +7,7 @@ import {sanaCanvasProviderTheme} from '../lib/theming/sanaTheme'; describe('CanvasProvider', () => { describe('console warnings', () => { it('should warn when sanaCanvasProviderTheme is used with global Sana theme', () => { - const consoleSpy = vi.spyOn(global.console, 'warn'); + const consoleSpy = vi.spyOn(global.console, 'warn').mockImplementation(() => {}); // Set data-theme on document document.documentElement.setAttribute('data-theme', 'sana-canvas'); @@ -28,7 +28,7 @@ describe('CanvasProvider', () => { }); it('should not warn when sanaCanvasProviderTheme is used without global Sana theme', () => { - const consoleSpy = vi.spyOn(global.console, 'warn'); + const consoleSpy = vi.spyOn(global.console, 'warn').mockImplementation(() => {}); render( @@ -42,7 +42,7 @@ describe('CanvasProvider', () => { }); it('should not warn when using a different theme', () => { - const consoleSpy = vi.spyOn(global.console, 'warn'); + const consoleSpy = vi.spyOn(global.console, 'warn').mockImplementation(() => {}); document.documentElement.setAttribute('data-theme', 'sana-canvas'); From c3a8a1baeda5a250e8f80a0d95c99e003d3e9f48 Mon Sep 17 00:00:00 2001 From: "manuel.carrera" Date: Fri, 31 Jul 2026 11:00:37 -0600 Subject: [PATCH 10/19] docs: Update JSDoc to clarify sanaCanvasProviderTheme is optional Clarifies that the theme prop is not needed when data-theme="sana-canvas" is set globally, as popups inherit CSS variables naturally. --- modules/react/common/lib/theming/README.md | 4 ++++ modules/react/common/lib/theming/sanaTheme.ts | 13 ++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/modules/react/common/lib/theming/README.md b/modules/react/common/lib/theming/README.md index 9693c15863..20216f715f 100644 --- a/modules/react/common/lib/theming/README.md +++ b/modules/react/common/lib/theming/README.md @@ -73,6 +73,10 @@ import {base} from '@workday/canvas-tokens-web'; For popup parity when using global Sana CSS, pass `sanaCanvasProviderTheme` at your root `CanvasProvider`. See the [Theming documentation](https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs) for details. +**Note:** In most cases with `data-theme="sana-canvas"` set globally, the theme prop is not needed +as popups inherit CSS variables naturally. The `sanaCanvasProviderTheme` is primarily for edge cases +like testing environments or custom popup containers. + ## Bidirectionality (RTL Support) ### Setting RTL Direction diff --git a/modules/react/common/lib/theming/sanaTheme.ts b/modules/react/common/lib/theming/sanaTheme.ts index f2f17a1512..85651fc25e 100644 --- a/modules/react/common/lib/theming/sanaTheme.ts +++ b/modules/react/common/lib/theming/sanaTheme.ts @@ -108,9 +108,20 @@ export const sanaCanvasNumericalTheme: CanvasNumericalBrandTheme = { * Pass to `CanvasProvider` at app root when using global Sana CSS — forwards Sana brand * variables to popup containers. * + * **Note:** This is optional when `data-theme="sana-canvas"` is set on ``. + * Popups naturally inherit CSS variables from the global theme. Only needed for: + * - Testing environments without global CSS + * - Custom popup containers outside normal document flow + * - Legacy applications transitioning to Sana + * * @example * ```tsx - * // index.css: import sana/_variables.css last; + * // Typical setup - no theme prop needed + * import '@workday/canvas-tokens-web/css/sana/_variables.css'; + * // + * + * + * // Edge case - testing without global CSS * * ``` */ From ccfae0e73f08e41d8e6d9b655ad491814e9d5beb Mon Sep 17 00:00:00 2001 From: "manuel.carrera" Date: Fri, 31 Jul 2026 11:06:39 -0600 Subject: [PATCH 11/19] docs: Add migration guide for simplified Sana Canvas setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents that sanaCanvasProviderTheme is no longer required when using global Sana CSS, simplifying the setup for most teams. 🤖 Generated with Claude Code Co-Authored-By: Claude --- .../llm/upgrade-guides/16.0-UPGRADE-GUIDE.md | 31 +++++++++++++++++++ modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx | 31 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md b/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md index dae1e5da8e..fc0873e719 100644 --- a/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md +++ b/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md @@ -75,12 +75,43 @@ them if they import the Sana variables and set `data-theme="sana-canvas"` global For the scoped theming API, see our [Theming documentation](https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs). +### Simplified Sana Canvas Setup + +If you're using Sana Canvas globally with `data-theme="sana-canvas"`, you no longer need to pass +`sanaCanvasProviderTheme` to CanvasProvider. Popups naturally inherit CSS variables from the +global theme. + +**Before:** +```tsx +import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; + + + + +``` + +**After:** +```tsx +import {CanvasProvider} from '@workday/canvas-kit-react/common'; + +// Simpler setup - no theme prop needed + + + +``` + +The `sanaCanvasProviderTheme` export remains available for edge cases like testing environments +without global CSS or custom popup containers, but is no longer required for typical usage. + +If you see a console warning about unnecessary theme usage, you can safely remove the theme prop. + ## Table of Contents - [Sana Canvas Theme](#sana-canvas-theme) - [Opting In](#opting-in) - [What Changes When You Opt In](#what-changes-when-you-opt-in) - [Scoped Theming](#scoped-theming) + - [Simplified Sana Canvas Setup](#simplified-sana-canvas-setup) - [Codemod](#codemod) - [Instructions](#instructions) - [New Components](#new-components) diff --git a/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx b/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx index 44e2e215fa..4bc4e46630 100644 --- a/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx +++ b/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx @@ -107,12 +107,43 @@ To restore the previous behavior, explicitly set `themeScope: 'full'`: This change provides more control over theming scope and prevents unintended overrides, but teams relying on the auto-generated color ramp need to explicitly opt into `'full'` scope. +### Simplified Sana Canvas Setup + +If you're using Sana Canvas globally with `data-theme="sana-canvas"`, you no longer need to pass +`sanaCanvasProviderTheme` to CanvasProvider. Popups naturally inherit CSS variables from the +global theme. + +**Before:** +```tsx +import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; + + + + +``` + +**After:** +```tsx +import {CanvasProvider} from '@workday/canvas-kit-react/common'; + +// Simpler setup - no theme prop needed + + + +``` + +The `sanaCanvasProviderTheme` export remains available for edge cases like testing environments +without global CSS or custom popup containers, but is no longer required for typical usage. + +If you see a console warning about unnecessary theme usage, you can safely remove the theme prop. + ## Table of Contents - [Sana Canvas Theme](#sana-canvas-theme) - [Opting In](#opting-in) - [What Changes When You Opt In](#what-changes-when-you-opt-in) - [Scoped Theming](#scoped-theming) + - [Simplified Sana Canvas Setup](#simplified-sana-canvas-setup) - [Codemod](#codemod) - [Instructions](#instructions) - [New Components](#new-components) From ea0c1e1bfdecb6bcbbbc6ad49b7cc3a4b42b4767 Mon Sep 17 00:00:00 2001 From: "manuel.carrera" Date: Fri, 31 Jul 2026 11:07:01 -0600 Subject: [PATCH 12/19] docs: Sync LLM upgrade guide with MDX version Adds missing 'Default Scope Change for Legacy Themes' section for consistency between MDX and markdown versions. --- .../llm/upgrade-guides/16.0-UPGRADE-GUIDE.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md b/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md index fc0873e719..e96e95af2e 100644 --- a/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md +++ b/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md @@ -75,6 +75,32 @@ them if they import the Sana variables and set `data-theme="sana-canvas"` global For the scoped theming API, see our [Theming documentation](https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs). +#### Default Scope Change for Legacy Themes + +**Important:** If you previously used `canvas.palette..main` only (without other palette properties) +to scope-theme your application, the default behavior has changed. In v16: + +- **Before v16:** Setting only `palette.primary.main` would automatically generate a full color ramp + (lightest, lighter, light, dark, darkest, contrast) and apply broad `system.color.brand.*` forwarding. +- **In v16:** Setting only `palette.primary.main` defaults to `'brand'` scope, which applies a narrower + set of variables (PrimaryButton and selected states only). + +To restore the previous behavior, explicitly set `themeScope: 'full'`: + +```jsx +// v15 behavior (implicit full scope) + + +// v16 - to get the same behavior as v15 + +``` + +This change provides more control over theming scope and prevents unintended overrides, but teams +relying on the auto-generated color ramp need to explicitly opt into `'full'` scope. + ### Simplified Sana Canvas Setup If you're using Sana Canvas globally with `data-theme="sana-canvas"`, you no longer need to pass From 0f552b834f285514377ecbe85b45dc477c399775 Mon Sep 17 00:00:00 2001 From: "manuel.carrera" Date: Fri, 31 Jul 2026 11:22:33 -0600 Subject: [PATCH 13/19] test: Add story demonstrating simplified Sana Canvas setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows that popups work correctly without sanaCanvasProviderTheme when global Sana CSS is active. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../examples/SimplifiedSanaSetup.stories.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 modules/react/common/stories/examples/SimplifiedSanaSetup.stories.tsx diff --git a/modules/react/common/stories/examples/SimplifiedSanaSetup.stories.tsx b/modules/react/common/stories/examples/SimplifiedSanaSetup.stories.tsx new file mode 100644 index 0000000000..83f59cc59a --- /dev/null +++ b/modules/react/common/stories/examples/SimplifiedSanaSetup.stories.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +import {SecondaryButton} from '@workday/canvas-kit-react/button'; +import {CanvasProvider} from '@workday/canvas-kit-react/common'; +import {Menu} from '@workday/canvas-kit-react/menu'; +import {Popup} from '@workday/canvas-kit-react/popup'; + +export default { + title: 'Features/Theming/Simplified Sana Setup', + parameters: { + docs: { + description: { + story: ` +This demonstrates the simplified Sana Canvas setup. When \`data-theme="sana-canvas"\` is set +globally, popups automatically inherit the theme without needing \`sanaCanvasProviderTheme\`. + `, + }, + }, + }, +}; + +export const SimplifiedSetup = () => { + return ( + + + Open Menu + + + Option 1 + Option 2 + Option 3 + + + + + ); +}; + +SimplifiedSetup.storyName = 'No Theme Prop Needed'; From 797e6900854078aec997046c0768f6db36199165 Mon Sep 17 00:00:00 2001 From: "manuel.carrera" Date: Fri, 31 Jul 2026 12:09:51 -0600 Subject: [PATCH 14/19] fix: Update setup --- README.md | 11 ++++++ modules/docs/llm/theming.md | 25 +++++++++--- .../llm/upgrade-guides/16.0-UPGRADE-GUIDE.md | 30 +++++++++----- modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx | 30 +++++++++----- modules/react/common/lib/CanvasProvider.tsx | 6 ++- modules/react/common/lib/theming/README.md | 25 +++++++++--- modules/react/common/lib/theming/sanaTheme.ts | 24 +++++++----- modules/react/common/lib/theming/types.ts | 2 +- .../examples/SimplifiedSanaSetup.stories.tsx | 39 ------------------- modules/react/common/stories/mdx/Theming.mdx | 30 +++++++++++--- .../mdx/examples/SimplifiedSanaSetup.tsx | 30 ++++++++++++++ .../2026-07-31-sana-theme-optional-design.md | 16 +++++--- 12 files changed, 176 insertions(+), 92 deletions(-) delete mode 100644 modules/react/common/stories/examples/SimplifiedSanaSetup.stories.tsx create mode 100644 modules/react/common/stories/mdx/examples/SimplifiedSanaSetup.tsx diff --git a/README.md b/README.md index e8ac951d3d..6e61792d4f 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,17 @@ export const App = () => { }; ``` +If you cannot control `` (embedded apps, microfrontends), pass `sanaCanvasProviderTheme` +instead so menus, selects, and other popups still get Sana brand variables: + +```jsx +import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; + + + + +``` + > **Note:** Don't use the `CanvasProvider` to theme, instead use our CSS tokens from > `@workday/canvas-tokens-web`. For more information, view our > [Token docs](https://workday.github.io/canvas-tokens/?path=/docs/docs-getting-started--docs). diff --git a/modules/docs/llm/theming.md b/modules/docs/llm/theming.md index 0260713a39..663357ba33 100644 --- a/modules/docs/llm/theming.md +++ b/modules/docs/llm/theming.md @@ -97,20 +97,35 @@ import {base} from '@workday/canvas-tokens-web'; ``` -Popups (menus, selects, modals) portal to `document.body` and inherit CSS variables from -`[data-theme="sana-canvas"]` automatically. In most cases, no theme prop is needed: +Popups (menus, selects, modals) portal to `document.body`. How theming reaches them: + +**Preferred — you control ``:** set `data-theme="sana-canvas"` on ``. Popups inherit +Sana CSS variables automatically; no `theme` prop needed: ```tsx import {CanvasProvider} from '@workday/canvas-kit-react/common'; -// Simple setup - popups inherit global Sana theme +// ``` -**Note:** The `sanaCanvasProviderTheme` export remains available for edge cases like testing -environments without global CSS or custom popup containers outside the normal document flow. +**Required — no access to ``:** embedded apps, microfrontends, and third-party shells often +cannot set attributes on ``. A nested `data-theme` on a wrapper does **not** apply to +portaled popups. Pass `sanaCanvasProviderTheme` so Canvas Kit forwards Sana brand variables onto +the popup stack container: + +```tsx +import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; + + + + +``` + +`sanaCanvasProviderTheme` is also useful in tests without global Sana CSS or with custom popup +hosts outside the normal document flow. See **Features/Theming → Sana Canvas** in Storybook for a side-by-side comparison of global and scoped branding. diff --git a/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md b/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md index e96e95af2e..02490844be 100644 --- a/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md +++ b/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md @@ -69,8 +69,9 @@ against `@workday/canvas-tokens-web/css/sana/_variables.css`: The `CanvasProvider` theming updates in [#4060](https://github.com/Workday/canvas-kit/pull/4060) are for **scoped** use cases — embedding -Canvas in another brand, multi-tenant sections, and popup parity. Application teams should not need -them if they import the Sana variables and set `data-theme="sana-canvas"` globally. +Canvas in another brand, multi-tenant sections, and popup parity when you cannot set +`data-theme="sana-canvas"` on ``. Application teams that control `` and import the +Sana variables globally do not need a `theme` prop for app-wide Sana. For the scoped theming API, see our [Theming documentation](https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs). @@ -103,9 +104,8 @@ relying on the auto-generated color ramp need to explicitly opt into `'full'` sc ### Simplified Sana Canvas Setup -If you're using Sana Canvas globally with `data-theme="sana-canvas"`, you no longer need to pass -`sanaCanvasProviderTheme` to CanvasProvider. Popups naturally inherit CSS variables from the -global theme. +If you can set `data-theme="sana-canvas"` on ``, you no longer need to pass +`sanaCanvasProviderTheme` to CanvasProvider. Popups inherit CSS variables from the document. **Before:** ```tsx @@ -116,20 +116,30 @@ import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react ``` -**After:** +**After (when you control ``):** ```tsx import {CanvasProvider} from '@workday/canvas-kit-react/common'; -// Simpler setup - no theme prop needed +// ``` -The `sanaCanvasProviderTheme` export remains available for edge cases like testing environments -without global CSS or custom popup containers, but is no longer required for typical usage. +**Still required — no access to ``:** embedded apps, microfrontends, and third-party shells +often cannot set attributes on ``. Nested `data-theme` does not reach portaled popups. Keep +passing `sanaCanvasProviderTheme` so menus, selects, and modals get Sana brand variables: -If you see a console warning about unnecessary theme usage, you can safely remove the theme prop. +```tsx +import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; + + + + +``` + +If you control `` and see a console warning about unnecessary theme usage, you can safely +remove the theme prop. ## Table of Contents diff --git a/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx b/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx index 4bc4e46630..78dae2311d 100644 --- a/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx +++ b/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx @@ -75,8 +75,9 @@ against `@workday/canvas-tokens-web/css/sana/_variables.css`: The `CanvasProvider` theming updates in [#4060](https://github.com/Workday/canvas-kit/pull/4060) are for **scoped** use cases — embedding -Canvas in another brand, multi-tenant sections, and popup parity. Application teams should not need -them if they import the Sana variables and set `data-theme="sana-canvas"` globally. +Canvas in another brand, multi-tenant sections, and popup parity when you cannot set +`data-theme="sana-canvas"` on ``. Application teams that control `` and import the +Sana variables globally do not need a `theme` prop for app-wide Sana. For the scoped theming API, see our [Theming documentation](https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs). @@ -109,9 +110,8 @@ relying on the auto-generated color ramp need to explicitly opt into `'full'` sc ### Simplified Sana Canvas Setup -If you're using Sana Canvas globally with `data-theme="sana-canvas"`, you no longer need to pass -`sanaCanvasProviderTheme` to CanvasProvider. Popups naturally inherit CSS variables from the -global theme. +If you can set `data-theme="sana-canvas"` on ``, you no longer need to pass +`sanaCanvasProviderTheme` to CanvasProvider. Popups inherit CSS variables from the document. **Before:** ```tsx @@ -122,20 +122,30 @@ import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react ``` -**After:** +**After (when you control ``):** ```tsx import {CanvasProvider} from '@workday/canvas-kit-react/common'; -// Simpler setup - no theme prop needed +// ``` -The `sanaCanvasProviderTheme` export remains available for edge cases like testing environments -without global CSS or custom popup containers, but is no longer required for typical usage. +**Still required — no access to ``:** embedded apps, microfrontends, and third-party shells +often cannot set attributes on ``. Nested `data-theme` does not reach portaled popups. Keep +passing `sanaCanvasProviderTheme` so menus, selects, and modals get Sana brand variables: -If you see a console warning about unnecessary theme usage, you can safely remove the theme prop. +```tsx +import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; + + + + +``` + +If you control `` and see a console warning about unnecessary theme usage, you can safely +remove the theme prop. ## Table of Contents diff --git a/modules/react/common/lib/CanvasProvider.tsx b/modules/react/common/lib/CanvasProvider.tsx index c914c34158..61199cebd9 100644 --- a/modules/react/common/lib/CanvasProvider.tsx +++ b/modules/react/common/lib/CanvasProvider.tsx @@ -206,8 +206,10 @@ export const CanvasProvider = ({ ) { console.warn( 'Canvas Kit: You are passing sanaCanvasProviderTheme to CanvasProvider but ' + - 'data-theme="sana-canvas" is already set globally. The theme prop is not needed ' + - 'in this case and can be removed for simpler setup. See: ' + + 'data-theme="sana-canvas" is already set on . The theme prop is not needed ' + + 'in this case and can be removed. Keep sanaCanvasProviderTheme when you cannot ' + + 'control (embedded apps / microfrontends) so popups still get Sana brand ' + + 'variables. See: ' + 'https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs' ); } diff --git a/modules/react/common/lib/theming/README.md b/modules/react/common/lib/theming/README.md index 20216f715f..17fff4743e 100644 --- a/modules/react/common/lib/theming/README.md +++ b/modules/react/common/lib/theming/README.md @@ -70,12 +70,27 @@ import {base} from '@workday/canvas-tokens-web';
``` -For popup parity when using global Sana CSS, pass `sanaCanvasProviderTheme` at your root -`CanvasProvider`. See the [Theming documentation](https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs) for details. +### Popups and `sanaCanvasProviderTheme` -**Note:** In most cases with `data-theme="sana-canvas"` set globally, the theme prop is not needed -as popups inherit CSS variables naturally. The `sanaCanvasProviderTheme` is primarily for edge cases -like testing environments or custom popup containers. +Popups (menus, selects, modals) portal to `document.body`. Theme inheritance depends on where +`data-theme="sana-canvas"` lives: + +- **You control ``:** set `data-theme="sana-canvas"` there. Popups inherit Sana variables — + no `theme` prop needed. +- **You cannot control ``** (embedded apps, microfrontends, third-party shells): pass + `sanaCanvasProviderTheme` to your root `CanvasProvider`. Nested `data-theme` on a wrapper does + not reach portaled popups; this preset forwards Sana brand variables onto the popup stack. + +```tsx +import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; + +// Required when is unavailable + + + +``` + +See the [Theming documentation](https://workday.github.io/canvas-kit/?path=/docs/features-theming-overview--docs) for details. ## Bidirectionality (RTL Support) diff --git a/modules/react/common/lib/theming/sanaTheme.ts b/modules/react/common/lib/theming/sanaTheme.ts index 85651fc25e..a82060436c 100644 --- a/modules/react/common/lib/theming/sanaTheme.ts +++ b/modules/react/common/lib/theming/sanaTheme.ts @@ -13,7 +13,7 @@ * | Preset | Use case | * |--------|----------| * | `sanaCanvasNumericalTheme` | Numerical `brand` shape for popup forwarding | - * | `sanaCanvasProviderTheme` | Same — pass to root `CanvasProvider` with global Sana CSS | + * | `sanaCanvasProviderTheme` | Same — pass to root `CanvasProvider` when `` is unavailable | */ import {base, brand} from '@workday/canvas-tokens-web'; @@ -105,23 +105,27 @@ export const sanaCanvasNumericalTheme: CanvasNumericalBrandTheme = { }; /** - * Pass to `CanvasProvider` at app root when using global Sana CSS — forwards Sana brand - * variables to popup containers. + * Pass to root `CanvasProvider` to forward Sana brand CSS variables onto popup containers + * (menus, selects, modals, tooltips). * - * **Note:** This is optional when `data-theme="sana-canvas"` is set on ``. - * Popups naturally inherit CSS variables from the global theme. Only needed for: - * - Testing environments without global CSS - * - Custom popup containers outside normal document flow - * - Legacy applications transitioning to Sana + * **When to use it** + * - **Required** when you cannot set `data-theme="sana-canvas"` on `` (embedded apps, + * microfrontends, third-party shells). Popups portal to `document.body` and will not inherit + * a nested `data-theme` — this preset copies Sana brand vars onto the popup stack container. + * - Also useful in tests without global Sana CSS, or custom popup hosts outside normal cascade. + * + * **When you can skip it** + * - Prefer setting `data-theme="sana-canvas"` on `` with Sana CSS imported. Popups then + * inherit brand variables from the document and no `theme` prop is needed. * * @example * ```tsx - * // Typical setup - no theme prop needed + * // Preferred — control * import '@workday/canvas-tokens-web/css/sana/_variables.css'; * // * * - * // Edge case - testing without global CSS + * // No access to — required for popup parity * * ``` */ diff --git a/modules/react/common/lib/theming/types.ts b/modules/react/common/lib/theming/types.ts index 1091bcbf45..e2bfee635d 100644 --- a/modules/react/common/lib/theming/types.ts +++ b/modules/react/common/lib/theming/types.ts @@ -356,7 +356,7 @@ export type CanvasThemingScope = 'brand' | 'full'; * }} /> * ``` * - * @see sanaCanvasProviderTheme for Sana global theme + popup parity + * @see sanaCanvasProviderTheme when `` is unavailable and popups need Sana brand forwarding */ export interface CanvasNumericalBrandTheme { brand?: { diff --git a/modules/react/common/stories/examples/SimplifiedSanaSetup.stories.tsx b/modules/react/common/stories/examples/SimplifiedSanaSetup.stories.tsx deleted file mode 100644 index 83f59cc59a..0000000000 --- a/modules/react/common/stories/examples/SimplifiedSanaSetup.stories.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; - -import {SecondaryButton} from '@workday/canvas-kit-react/button'; -import {CanvasProvider} from '@workday/canvas-kit-react/common'; -import {Menu} from '@workday/canvas-kit-react/menu'; -import {Popup} from '@workday/canvas-kit-react/popup'; - -export default { - title: 'Features/Theming/Simplified Sana Setup', - parameters: { - docs: { - description: { - story: ` -This demonstrates the simplified Sana Canvas setup. When \`data-theme="sana-canvas"\` is set -globally, popups automatically inherit the theme without needing \`sanaCanvasProviderTheme\`. - `, - }, - }, - }, -}; - -export const SimplifiedSetup = () => { - return ( - - - Open Menu - - - Option 1 - Option 2 - Option 3 - - - - - ); -}; - -SimplifiedSetup.storyName = 'No Theme Prop Needed'; diff --git a/modules/react/common/stories/mdx/Theming.mdx b/modules/react/common/stories/mdx/Theming.mdx index 04d92ea338..cb0d4e9c88 100644 --- a/modules/react/common/stories/mdx/Theming.mdx +++ b/modules/react/common/stories/mdx/Theming.mdx @@ -3,6 +3,7 @@ import {Meta} from '@storybook/blocks'; import {ExampleCodeBlock} from '@workday/canvas-kit-docs'; import {ThemingBrandScope} from './examples/ThemingBrandScope'; +import {SimplifiedSetup} from './examples/SimplifiedSanaSetup'; @@ -102,20 +103,39 @@ import {base} from '@workday/canvas-tokens-web'; -Popups (menus, selects, modals) portal to `document.body` and inherit CSS variables from -`[data-theme="sana-canvas"]` automatically. In most cases, no theme prop is needed: +Popups (menus, selects, modals) portal to `document.body`. How theming reaches them: + +**Preferred — you control ``:** set `data-theme="sana-canvas"` on ``. Popups inherit +Sana CSS variables automatically; no `theme` prop needed: ```tsx import {CanvasProvider} from '@workday/canvas-kit-react/common'; -// Simple setup - popups inherit global Sana theme +// ``` -**Note:** The `sanaCanvasProviderTheme` export remains available for edge cases like testing -environments without global CSS or custom popup containers outside the normal document flow. +**Required — no access to ``:** embedded apps, microfrontends, and third-party shells often +cannot set attributes on ``. A nested `data-theme` on a wrapper does **not** apply to +portaled popups. Pass `sanaCanvasProviderTheme` so Canvas Kit forwards Sana brand variables onto +the popup stack container: + +```tsx +import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; +// Make sure to import the Sana variables last! +import '@workday/canvas-tokens-web/css/sana/_variables.css'; + + + + +``` + +`sanaCanvasProviderTheme` is also useful in tests without global Sana CSS or with custom popup +hosts outside the normal document flow. + + See **Features/Theming → Sana Canvas** in Storybook for a side-by-side comparison of global and scoped branding. diff --git a/modules/react/common/stories/mdx/examples/SimplifiedSanaSetup.tsx b/modules/react/common/stories/mdx/examples/SimplifiedSanaSetup.tsx new file mode 100644 index 0000000000..d9b11e50b7 --- /dev/null +++ b/modules/react/common/stories/mdx/examples/SimplifiedSanaSetup.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import {PrimaryButton, SecondaryButton} from '@workday/canvas-kit-react/button'; +import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; +import {Menu} from '@workday/canvas-kit-react/menu'; +import {Popup, useCloseOnOutsideClick, usePopupModel} from '@workday/canvas-kit-react/popup'; + +export const SimplifiedSetup = () => { + const myModel = usePopupModel(); + useCloseOnOutsideClick(myModel); + return ( + + + Open Menu + + + + + Option 1 + Option 2 + Option 3 + + Hello World + + + + + + ); +}; diff --git a/specs/2026-07-31-sana-theme-optional-design.md b/specs/2026-07-31-sana-theme-optional-design.md index 6d1bc76879..7e6ec821fa 100644 --- a/specs/2026-07-31-sana-theme-optional-design.md +++ b/specs/2026-07-31-sana-theme-optional-design.md @@ -38,18 +38,24 @@ The CSS setup remains unchanged: ### When Theme Prop Is Still Needed -The `sanaCanvasProviderTheme` remains available for specific scenarios: +The `sanaCanvasProviderTheme` remains available — and is **required for popup parity** — in these scenarios: -1. **Scoped Theming**: When a section needs different branding +1. **No access to ``**: Embedded apps, microfrontends, and third-party shells that cannot set + `data-theme="sana-canvas"` on ``. Nested `data-theme` does not reach portaled popups. + ```tsx + + + + ``` + +2. **Scoped Theming**: When a section needs different branding ```tsx ``` -2. **Testing**: When global CSS isn't loaded in test environments - -3. **Legacy Migration**: Applications transitioning to Sana incrementally +3. **Testing**: When global CSS isn't loaded in test environments 4. **Edge Cases**: Custom popup containers rendered outside normal document flow From c3952e0eead204681f179f4af5b06856f33e71d7 Mon Sep 17 00:00:00 2001 From: "manuel.carrera" Date: Fri, 31 Jul 2026 12:19:53 -0600 Subject: [PATCH 15/19] fix: Update upgrade guide --- modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md b/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md index ade8fef18a..03e38dd3cf 100644 --- a/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md +++ b/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md @@ -1199,8 +1199,8 @@ things you'll want to keep in mind. dependencies on your own. - We recommend upgrading dependencies before running the codemod. - Always review your `package.json` files to make sure your dependency versions look correct. -- The codemod will not handle every breaking change in this upgrade. You will likely need to make - some manual changes to be compatible. Use our Upgrade Guide as a checklist. +- The codemod will not handle every breaking change in this upgrade. You will likely need to make some manual + changes to be compatible. Use our Upgrade Guide as a checklist. - Codemods are not bulletproof. - Conduct a thorough PR and QA review of all changes to ensure no regressions were introduced. - As a safety precaution, we recommend committing the changes from the codemod as a single From f8734a66f8b024ddd549585e8b92598896aa9483 Mon Sep 17 00:00:00 2001 From: "manuel.carrera" Date: Fri, 31 Jul 2026 12:28:55 -0600 Subject: [PATCH 16/19] fix: Address pr comments --- .../llm/upgrade-guides/16.0-UPGRADE-GUIDE.md | 16 +++++--- modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx | 17 +++++---- modules/react/common/lib/CanvasProvider.tsx | 21 +++++++---- .../react/common/lib/theming/brandScope.ts | 25 ++++++------- modules/react/common/spec/brandScope.spec.ts | 37 +++++++++++++++++++ .../react/popup/lib/hooks/usePopupStack.ts | 17 +++++---- .../react/popup/spec/usePopupStack.spec.tsx | 17 +++++++++ modules/react/testing/lib/StaticStates.tsx | 10 ++--- 8 files changed, 114 insertions(+), 46 deletions(-) diff --git a/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md b/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md index 03e38dd3cf..6777fd1d8e 100644 --- a/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md +++ b/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md @@ -39,7 +39,10 @@ selector and `:root` have equal specificity — source order determines the winn @import '@workday/canvas-tokens-web/css/sana/_variables.css'; ``` -Set the theme attribute on `` (not a nested element): +Set `data-theme="sana-canvas"` on `` when you control the document root (preferred). Nested +elements are not enough for portaled popups (menus, selects, modals) — those render under +`document.body` and only inherit theme from ``, or from `sanaCanvasProviderTheme` on +`CanvasProvider` when `` is unavailable. ```html @@ -51,8 +54,9 @@ Set the theme attribute on `` (not a nested element): ### What Changes When You Opt In -Things that change are things that use the primary brand token and the Sana neutral ramp. Verified -against `@workday/canvas-tokens-web/css/sana/_variables.css`: +Things that change are primary brand consumers and Sana's neutral color scale +(`--cnvs-brand-neutral-*`, which replaces classic slate neutrals). Verified against +`@workday/canvas-tokens-web/css/sana/_variables.css`: - **Brand primary consumers flip.** `--cnvs-sys-color-brand-accent-primary` and `-accent-action` re-point from blue to `--cnvs-brand-neutral-975`, and `-brand-fg-primary-default/-strong` to @@ -61,9 +65,9 @@ against `@workday/canvas-tokens-web/css/sana/_variables.css`: - **Focus does not.** `--cnvs-sys-color-brand-focus-primary` and `-border-primary` stay `blue-500`. - **`--cnvs-brand-primary-600` is intentionally _not_ redefined** — it stays the consumer's brand hook. -- **The full neutral ramp is replaced** (`--cnvs-brand-neutral-*`, all steps plus alphas), plus - selected `critical`, `caution`, `positive`, shapes (`sm`, `xs`, `xxl`, `xxxl`), surfaces/overlays, - and chart ramps. +- **The full neutral color scale is replaced** (`--cnvs-brand-neutral-*`, all steps plus alphas), + plus selected `critical`, `caution`, `positive`, shapes (`sm`, `xs`, `xxl`, `xxxl`), + surfaces/overlays, and chart ramps. ### Scoped Theming diff --git a/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx b/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx index 8ce2408c1e..00f1a8259e 100644 --- a/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx +++ b/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx @@ -44,7 +44,10 @@ selector and `:root` have equal specificity — source order determines the winn @import '@workday/canvas-tokens-web/css/sana/_variables.css'; ``` -Set the theme attribute on `` (not a nested element): +Set `data-theme="sana-canvas"` on `` when you control the document root (preferred). Nested +elements are not enough for portaled popups (menus, selects, modals) — those render under +`document.body` and only inherit theme from ``, or from `sanaCanvasProviderTheme` on +`CanvasProvider` when `` is unavailable. ```html @@ -54,11 +57,11 @@ Set the theme attribute on `` (not a nested element): > `[data-theme="sana-canvas"]` overrides, so removing the attribute is how you get classic Canvas — > there's nothing to undo it with. - ### What Changes When You Opt In -Things that change are things that use the primary brand token and the Sana neutral ramp. Verified -against `@workday/canvas-tokens-web/css/sana/_variables.css`: +Things that change are primary brand consumers and Sana's neutral color scale +(`--cnvs-brand-neutral-*`, which replaces classic slate neutrals). Verified against +`@workday/canvas-tokens-web/css/sana/_variables.css`: - **Brand primary consumers flip.** `--cnvs-sys-color-brand-accent-primary` and `-accent-action` re-point from blue to `--cnvs-brand-neutral-975`, and `-brand-fg-primary-default/-strong` to @@ -67,9 +70,9 @@ against `@workday/canvas-tokens-web/css/sana/_variables.css`: - **Focus does not.** `--cnvs-sys-color-brand-focus-primary` and `-border-primary` stay `blue-500`. - **`--cnvs-brand-primary-600` is intentionally _not_ redefined** — it stays the consumer's brand hook. -- **The full neutral ramp is replaced** (`--cnvs-brand-neutral-*`, all steps plus alphas), plus - selected `critical`, `caution`, `positive`, shapes (`sm`, `xs`, `xxl`, `xxxl`), surfaces/overlays, - and chart ramps. +- **The full neutral color scale is replaced** (`--cnvs-brand-neutral-*`, all steps plus alphas), + plus selected `critical`, `caution`, `positive`, shapes (`sm`, `xs`, `xxl`, `xxxl`), + surfaces/overlays, and chart ramps. ### Scoped Theming diff --git a/modules/react/common/lib/CanvasProvider.tsx b/modules/react/common/lib/CanvasProvider.tsx index 61199cebd9..9bdf69697e 100644 --- a/modules/react/common/lib/CanvasProvider.tsx +++ b/modules/react/common/lib/CanvasProvider.tsx @@ -216,18 +216,24 @@ export const CanvasProvider = ({ } }, [theme]); + // Computed className/style win over consumer props — do not re-spread `props` over them. const {className, style, ...elemProps} = useCanvasThemeToCssVars(theme, props, themeScope); const cache = getCache(); - const rest = {...elemProps, ...props}; // Read parent context to support nested scoped providers const parentBrandStyle = React.useContext(CanvasBrandStyleContext); - // Merge parent style with current style, with current taking precedence - const mergedBrandStyle = React.useMemo( - () => ({...parentBrandStyle, ...style}), - [parentBrandStyle, style] - ); + // Popup forwarding only needs CSS custom properties (not consumer layout styles). + const mergedBrandStyle = React.useMemo(() => { + const merged: React.CSSProperties = {}; + for (const [key, value] of Object.entries({...parentBrandStyle, ...style})) { + if (key.startsWith('--') && value != null && value !== false) { + // @ts-ignore - CSS custom property key + merged[key] = String(value); + } + } + return merged; + }, [parentBrandStyle, style]); const emotionTheme = theme ? isNumericalTheme(theme) @@ -241,8 +247,9 @@ export const CanvasProvider = ({ ? theme.direction || defaultCanvasTheme.direction : theme?.canvas?.direction || defaultCanvasTheme.direction } + className={className} + {...(elemProps as React.HTMLAttributes)} style={style} - {...(rest as React.HTMLAttributes)} > {children} diff --git a/modules/react/common/lib/theming/brandScope.ts b/modules/react/common/lib/theming/brandScope.ts index 4f22b6b3ce..65189c4c68 100644 --- a/modules/react/common/lib/theming/brandScope.ts +++ b/modules/react/common/lib/theming/brandScope.ts @@ -92,7 +92,7 @@ export function writeNumericalBrandRamp( return; } (Object.keys(ramp) as Array).forEach(rampKey => { - if (options?.skipKeys?.has(rampKey)) { + if (options?.skipKeys?.has(String(rampKey))) { return; } const value = ramp[rampKey as keyof typeof ramp]; @@ -490,23 +490,20 @@ export function writeNumericalTheme( if (brandPalette.primary?.['600'] && Object.keys(brandPalette.primary).length === 1) { applyPrimaryBrandBundle(brandPalette.primary['600'], style); skipRamp.primary = new Set(['600']); - skipRamp.action = new Set(['base', 'dark', 'darkest']); + // Do not skip action keys — explicit `brand.action.*` must win over derived shades + // written by applyPrimaryBrandBundle (writeNumericalBrandRamp runs after). } + // Only the main ramp key triggers the family shortcut. Lone `'500'` (focus/border) must + // write 1:1 and must not overwrite critical600 / caution400 via the bundle. const critical = brandPalette.critical; - if (critical && Object.keys(critical).length === 1) { - const criticalColor = critical['600'] ?? critical['500']; - if (criticalColor) { - applyCriticalBrandBundle(criticalColor, style); - skipRamp.critical = new Set(Object.keys(critical)); - } + if (critical?.['600'] && Object.keys(critical).length === 1) { + applyCriticalBrandBundle(critical['600'], style); + skipRamp.critical = new Set(['600']); } const caution = brandPalette.caution; - if (caution && Object.keys(caution).length === 1) { - const cautionColor = caution['400'] ?? caution['500']; - if (cautionColor) { - applyCautionBrandBundle(cautionColor, style); - skipRamp.caution = new Set(Object.keys(caution)); - } + if (caution?.['400'] && Object.keys(caution).length === 1) { + applyCautionBrandBundle(caution['400'], style); + skipRamp.caution = new Set(['400']); } if (brandPalette.positive?.['600'] && Object.keys(brandPalette.positive).length === 1) { applyPositiveBrandBundle(brandPalette.positive['600'], style); diff --git a/modules/react/common/spec/brandScope.spec.ts b/modules/react/common/spec/brandScope.spec.ts index 03eb6ee3c5..c03aa8f171 100644 --- a/modules/react/common/spec/brandScope.spec.ts +++ b/modules/react/common/spec/brandScope.spec.ts @@ -106,4 +106,41 @@ describe('writeNumericalTheme', () => { expect(style[system.color.brand.accent.positive as string]).toBe('darkolivegreen'); expect(style[system.color.brand.focus.primary as string]).toBe('turquoise'); }); + + it('lets explicit action keys win over primary[600] shortcut', () => { + const style: Record = {}; + writeNumericalTheme( + { + brand: { + primary: {'600': 'purple'}, + action: {base: 'navy', dark: 'midnight', accent: 'turquoise'}, + }, + }, + style, + 'brand' + ); + + expect(style[brand.action.base as string]).toBe('navy'); + expect(style[brand.action.dark as string]).toBe('midnight'); + expect(style[brand.action.accent as string]).toBe('turquoise'); + expect(style[brand.primary600 as string]).toBe('purple'); + }); + + it('writes lone critical[500] without overwriting critical600', () => { + const style: Record = {}; + writeNumericalTheme({brand: {critical: {'500': 'orange'}}}, style, 'brand'); + + expect(style[brand.critical500 as string]).toBe('orange'); + expect(style[brand.critical600 as string]).toBeUndefined(); + expect(style[brand.error.base as string]).toBeUndefined(); + }); + + it('writes lone caution[500] without overwriting caution400', () => { + const style: Record = {}; + writeNumericalTheme({brand: {caution: {'500': 'gold'}}}, style, 'brand'); + + expect(style[brand.caution500 as string]).toBe('gold'); + expect(style[brand.caution400 as string]).toBeUndefined(); + expect(style[brand.alert.base as string]).toBeUndefined(); + }); }); diff --git a/modules/react/popup/lib/hooks/usePopupStack.ts b/modules/react/popup/lib/hooks/usePopupStack.ts index b6b2259cc2..d8f3efc147 100644 --- a/modules/react/popup/lib/hooks/usePopupStack.ts +++ b/modules/react/popup/lib/hooks/usePopupStack.ts @@ -66,22 +66,25 @@ export const usePopupStack = ( return localRef.current; }); - // Forward only theme overrides (style) to the popup container when a theme was provided via - // CanvasProvider theme prop. We do NOT apply defaultBranding (className) so we don't create a - // cascade barrier—only the CSS variables the consumer overrode are set. This effect runs - // before PopupStack.add below so the container has the theme before it's shown (avoids blue→magenta flash). + // Forward only CSS custom properties to the popup container when a theme was provided via + // CanvasProvider. We do NOT apply defaultBranding (className) so we don't create a cascade + // barrier. Filter to `--*` keys and string values so consumer layout styles from the provider + // are not copied onto the popup stack. Runs before PopupStack.add to avoid a theme flash. React.useLayoutEffect(() => { const element = localRef.current; if (!element) { return undefined; } - const styleKeys = Object.keys(style); + const styleKeys = Object.keys(style).filter(key => key.startsWith('--')); if (styleKeys.length === 0) { return undefined; } for (const key of styleKeys) { - // @ts-ignore - token keys are CSS custom property names - element.style.setProperty(key, style[key]); + const value = style[key as keyof typeof style]; + if (value == null || value === false) { + continue; + } + element.style.setProperty(key, String(value)); } // No cleanup: leave theme on container so reopening doesn't flash return undefined; diff --git a/modules/react/popup/spec/usePopupStack.spec.tsx b/modules/react/popup/spec/usePopupStack.spec.tsx index 8523d5a878..864277ee35 100644 --- a/modules/react/popup/spec/usePopupStack.spec.tsx +++ b/modules/react/popup/spec/usePopupStack.spec.tsx @@ -121,6 +121,23 @@ describe('usePopupStack', () => { } }); + it('should not forward non-CSS-variable styles from CanvasProvider to the popup', async () => { + const wrapper = ({children}: {children: React.ReactNode}) => ( + + {children} + + ); + + const {result} = renderHook(() => usePopupStack(), {wrapper}); + const container = result.current.current; + + await waitFor(() => { + expect(container?.style.getPropertyValue('--cnvs-brand-primary-600')).toBe('#123456'); + }); + + expect(container?.style.padding).toBe(''); + }); + it('should merge styles from nested CanvasProviders', async () => { const parentTheme = { brand: { diff --git a/modules/react/testing/lib/StaticStates.tsx b/modules/react/testing/lib/StaticStates.tsx index cafdd9e6f1..32ea607fe2 100644 --- a/modules/react/testing/lib/StaticStates.tsx +++ b/modules/react/testing/lib/StaticStates.tsx @@ -47,11 +47,11 @@ export const StaticStates: React.FC< ); localTheme._styleRewriteFn = convertToStaticStates; + // Nest ThemeProvider *inside* CanvasProvider so CanvasProvider's own ThemeProvider + // (used for legacy Emotion theme consumers) does not wipe `_styleRewriteFn`. return ( - - - {children} - - + + {children} + ); }; From 649ff6d894c3d800b9cc31f5685f293efa3dbb3e Mon Sep 17 00:00:00 2001 From: "manuel.carrera" Date: Fri, 31 Jul 2026 12:54:44 -0600 Subject: [PATCH 17/19] fix: Update docs --- modules/docs/llm/theming.md | 8 ++-- .../llm/upgrade-guides/16.0-UPGRADE-GUIDE.md | 20 ++++++---- modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx | 20 ++++++---- modules/docs/tsconfig.es6.json | 1 + modules/docs/tsconfig.json | 11 +++++- modules/docs/utils/build-specifications.js | 17 +++++--- modules/docs/utils/parseSpecFile.ts | 39 +++++++++++++++---- modules/react/common/lib/CanvasProvider.tsx | 4 +- modules/react/common/stories/mdx/Theming.mdx | 8 ++-- .../react/popup/lib/hooks/usePopupStack.ts | 4 +- .../2026-07-31-sana-theme-optional-design.md | 4 +- 11 files changed, 95 insertions(+), 41 deletions(-) diff --git a/modules/docs/llm/theming.md b/modules/docs/llm/theming.md index 663357ba33..12affce76b 100644 --- a/modules/docs/llm/theming.md +++ b/modules/docs/llm/theming.md @@ -97,7 +97,8 @@ import {base} from '@workday/canvas-tokens-web';
``` -Popups (menus, selects, modals) portal to `document.body`. How theming reaches them: +Popups (including menus, selects, modals, and toasts) portal to `document.body` — outside the +parent component's DOM hierarchy. How theming reaches them: **Preferred — you control ``:** set `data-theme="sana-canvas"` on ``. Popups inherit Sana CSS variables automatically; no `theme` prop needed: @@ -127,8 +128,9 @@ import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react `sanaCanvasProviderTheme` is also useful in tests without global Sana CSS or with custom popup hosts outside the normal document flow. -See **Features/Theming → Sana Canvas** in Storybook for a side-by-side comparison of global and -scoped branding. +See the +[Sana Canvas](https://workday.github.io/canvas-kit/?path=/story/features-theming--sana-canvas) +Storybook story for a side-by-side comparison of global and scoped branding. View token documentation [here](https://workday.github.io/canvas-tokens/?path=/docs/docs-getting-started--docs). diff --git a/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md b/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md index 6777fd1d8e..ec4200302a 100644 --- a/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md +++ b/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md @@ -16,8 +16,8 @@ that updates brand colors, neutrals, surfaces, and shapes at the application lev ## Sana Canvas Theme -v16 components are Sana-aligned out of the box. To opt your **application** into the full Sana Canvas -theme — brand neutrals, surfaces, and shapes — import the Sana CSS variables and set +Your application is Sana-aligned out of the box when the Sana Canvas theme is applied. To opt in — +brand neutrals, surfaces, and shapes — import the Sana CSS variables and set `data-theme="sana-canvas"` on ``. > **Note:** All visual updates in this guide apply to both the Default Canvas theme and the Sana @@ -40,9 +40,10 @@ selector and `:root` have equal specificity — source order determines the winn ``` Set `data-theme="sana-canvas"` on `` when you control the document root (preferred). Nested -elements are not enough for portaled popups (menus, selects, modals) — those render under -`document.body` and only inherit theme from ``, or from `sanaCanvasProviderTheme` on -`CanvasProvider` when `` is unavailable. +elements are not enough for portaled popups (including all Canvas Kit popups such as menus, +selects, modals, and toasts) — those render under `document.body` via React portals, outside the +parent component's DOM hierarchy, and only inherit theme from ``, or from +`sanaCanvasProviderTheme` on `CanvasProvider` when `` is unavailable. ```html @@ -109,7 +110,9 @@ relying on the auto-generated color ramp need to explicitly opt into `'full'` sc ### Simplified Sana Canvas Setup If you can set `data-theme="sana-canvas"` on ``, you no longer need to pass -`sanaCanvasProviderTheme` to CanvasProvider. Popups inherit CSS variables from the document. +`sanaCanvasProviderTheme` to CanvasProvider. Popups are called out specifically because Canvas Kit +renders them through React portals under `document.body` — outside the parent component's DOM +hierarchy — so they inherit CSS variables from ``, not from a nested wrapper. **Before:** ```tsx @@ -131,8 +134,9 @@ import {CanvasProvider} from '@workday/canvas-kit-react/common'; ``` **Still required — no access to ``:** embedded apps, microfrontends, and third-party shells -often cannot set attributes on ``. Nested `data-theme` does not reach portaled popups. Keep -passing `sanaCanvasProviderTheme` so menus, selects, and modals get Sana brand variables: +often cannot set attributes on ``. Nested `data-theme` does not reach portaled popups +(including all Canvas Kit popups such as menus, selects, modals, and toasts). Keep passing +`sanaCanvasProviderTheme` so those surfaces get Sana brand variables: ```tsx import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; diff --git a/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx b/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx index 00f1a8259e..ccc90f1571 100644 --- a/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx +++ b/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx @@ -21,8 +21,8 @@ that updates brand colors, neutrals, surfaces, and shapes at the application lev ## Sana Canvas Theme -v16 components are Sana-aligned out of the box. To opt your **application** into the full Sana Canvas -theme — brand neutrals, surfaces, and shapes — import the Sana CSS variables and set +Your application is Sana-aligned out of the box when the Sana Canvas theme is applied. To opt in — +brand neutrals, surfaces, and shapes — import the Sana CSS variables and set `data-theme="sana-canvas"` on ``. > **Note:** All visual updates in this guide apply to both the Default Canvas theme and the Sana @@ -45,9 +45,10 @@ selector and `:root` have equal specificity — source order determines the winn ``` Set `data-theme="sana-canvas"` on `` when you control the document root (preferred). Nested -elements are not enough for portaled popups (menus, selects, modals) — those render under -`document.body` and only inherit theme from ``, or from `sanaCanvasProviderTheme` on -`CanvasProvider` when `` is unavailable. +elements are not enough for portaled popups (including all Canvas Kit popups such as menus, +selects, modals, and toasts) — those render under `document.body` via React portals, outside the +parent component's DOM hierarchy, and only inherit theme from ``, or from +`sanaCanvasProviderTheme` on `CanvasProvider` when `` is unavailable. ```html @@ -114,7 +115,9 @@ relying on the auto-generated color ramp need to explicitly opt into `'full'` sc ### Simplified Sana Canvas Setup If you can set `data-theme="sana-canvas"` on ``, you no longer need to pass -`sanaCanvasProviderTheme` to CanvasProvider. Popups inherit CSS variables from the document. +`sanaCanvasProviderTheme` to CanvasProvider. Popups are called out specifically because Canvas Kit +renders them through React portals under `document.body` — outside the parent component's DOM +hierarchy — so they inherit CSS variables from ``, not from a nested wrapper. **Before:** ```tsx @@ -136,8 +139,9 @@ import {CanvasProvider} from '@workday/canvas-kit-react/common'; ``` **Still required — no access to ``:** embedded apps, microfrontends, and third-party shells -often cannot set attributes on ``. Nested `data-theme` does not reach portaled popups. Keep -passing `sanaCanvasProviderTheme` so menus, selects, and modals get Sana brand variables: +often cannot set attributes on ``. Nested `data-theme` does not reach portaled popups +(including all Canvas Kit popups such as menus, selects, modals, and toasts). Keep passing +`sanaCanvasProviderTheme` so those surfaces get Sana brand variables: ```tsx import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; diff --git a/modules/docs/tsconfig.es6.json b/modules/docs/tsconfig.es6.json index 601797a826..a73d46e4b9 100644 --- a/modules/docs/tsconfig.es6.json +++ b/modules/docs/tsconfig.es6.json @@ -2,6 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "declaration": true, + "rootDir": ".", "outDir": "dist/es6", "tsBuildInfoFile": "./.build-info/tsconfig.es6.tsbuildinfo" } diff --git a/modules/docs/tsconfig.json b/modules/docs/tsconfig.json index dfb3fcb949..ba4bd26c36 100644 --- a/modules/docs/tsconfig.json +++ b/modules/docs/tsconfig.json @@ -1,4 +1,13 @@ { "extends": "../../tsconfig.json", - "exclude": ["node_modules", "ts-tmp", "dist", "**/spec", "**/fixtures", "**/stories", "**/lib/stackblitz"] + "exclude": [ + "node_modules", + "ts-tmp", + "dist", + "**/spec", + "**/fixtures", + "**/stories", + "**/lib/stackblitz", + "mdx" + ] } diff --git a/modules/docs/utils/build-specifications.js b/modules/docs/utils/build-specifications.js index 6a6ab40f6e..2fcb549660 100644 --- a/modules/docs/utils/build-specifications.js +++ b/modules/docs/utils/build-specifications.js @@ -1,11 +1,18 @@ const fs = require('fs'); const util = require('util'); const path = require('path'); +const mkdirp = require('mkdirp'); const writeFile = util.promisify(fs.writeFile); const getSpecifications = require('./get-specifications'); -getSpecifications().then(async specs => { - const contents = `module.exports = {specifications: ${JSON.stringify(specs, null, ' ')}};`; - - await writeFile(path.join(__dirname, '../dist/es6/lib/specs.js'), contents); -}); +getSpecifications() + .then(async specs => { + const contents = `module.exports = {specifications: ${JSON.stringify(specs, null, ' ')}};`; + const outFile = path.join(__dirname, '../dist/es6/lib/specs.js'); + await mkdirp(path.dirname(outFile)); + await writeFile(outFile, contents); + }) + .catch(error => { + console.error(error); + process.exitCode = 1; + }); diff --git a/modules/docs/utils/parseSpecFile.ts b/modules/docs/utils/parseSpecFile.ts index 45ebc5f21f..c4de3d38ef 100644 --- a/modules/docs/utils/parseSpecFile.ts +++ b/modules/docs/utils/parseSpecFile.ts @@ -34,15 +34,38 @@ export async function parseSpecFile(file: string): Promise { .readFile(file) .then(contents => contents.toString()) .then(contents => - contents.replace(/import (.+) from .+/g, (substr: string, imports: string) => { - if (imports.includes('{')) { - return `const ${imports.replace(/[{}]/g, '')} = () => {}`; + // Strip imports before transpile. Use [\s\S] so multiline named imports are matched — + // otherwise typescript.transpile leaves `require(...)` calls that fail under eval. + contents.replace( + /import\s+([\s\S]+?)\s+from\s+['"][^'"]+['"];?/g, + (_substr: string, imports: string) => { + const trimmed = imports.trim(); + if (trimmed.includes('{')) { + const names = trimmed + .replace(/[{}]/g, '') + .split(',') + .map(part => { + // support `Foo as Bar` and whitespace/newlines + const pieces = part + .trim() + .split(/\s+as\s+|\s+/) + .filter(Boolean); + return pieces[pieces.length - 1]; + }) + .filter(Boolean); + // Stub as both a component and a CSF story object (`Example.render`) + return names + .map(name => `const ${name} = Object.assign(() => {}, {render: () => {}});`) + .join('\n'); + } + if (/react/i.test(trimmed)) { + return `const React = {createElement: () => {}};`; + } + // default / namespace imports — stub the binding name + const name = trimmed.replace(/^\*\s+as\s+/, '').trim(); + return name ? `const ${name} = () => {};` : ''; } - if (/react/g.test(imports)) { - return `const React = {createElement: () => {}}`; - } - return ''; - }) + ) ) // remove imports .then(contents => typescript.transpile(contents, {jsx: typescript.JsxEmit.React})) .then(contents => { diff --git a/modules/react/common/lib/CanvasProvider.tsx b/modules/react/common/lib/CanvasProvider.tsx index 9bdf69697e..4d53d6df26 100644 --- a/modules/react/common/lib/CanvasProvider.tsx +++ b/modules/react/common/lib/CanvasProvider.tsx @@ -227,9 +227,9 @@ export const CanvasProvider = ({ const mergedBrandStyle = React.useMemo(() => { const merged: React.CSSProperties = {}; for (const [key, value] of Object.entries({...parentBrandStyle, ...style})) { - if (key.startsWith('--') && value != null && value !== false) { + if (key.startsWith('--') && typeof value === 'string') { // @ts-ignore - CSS custom property key - merged[key] = String(value); + merged[key] = value; } } return merged; diff --git a/modules/react/common/stories/mdx/Theming.mdx b/modules/react/common/stories/mdx/Theming.mdx index cb0d4e9c88..f48aeb44b3 100644 --- a/modules/react/common/stories/mdx/Theming.mdx +++ b/modules/react/common/stories/mdx/Theming.mdx @@ -103,7 +103,8 @@ import {base} from '@workday/canvas-tokens-web'; -Popups (menus, selects, modals) portal to `document.body`. How theming reaches them: +Popups (including menus, selects, modals, and toasts) portal to `document.body` — outside the +parent component's DOM hierarchy. How theming reaches them: **Preferred — you control ``:** set `data-theme="sana-canvas"` on ``. Popups inherit Sana CSS variables automatically; no `theme` prop needed: @@ -137,8 +138,9 @@ hosts outside the normal document flow. -See **Features/Theming → Sana Canvas** in Storybook for a side-by-side comparison of global and -scoped branding. +See the +[Sana Canvas](?path=/story/features-theming--sana-canvas) Storybook story for a side-by-side +comparison of global and scoped branding. View token documentation [here](https://workday.github.io/canvas-tokens/?path=/docs/docs-getting-started--docs). diff --git a/modules/react/popup/lib/hooks/usePopupStack.ts b/modules/react/popup/lib/hooks/usePopupStack.ts index d8f3efc147..5a8efbfebd 100644 --- a/modules/react/popup/lib/hooks/usePopupStack.ts +++ b/modules/react/popup/lib/hooks/usePopupStack.ts @@ -81,10 +81,10 @@ export const usePopupStack = ( } for (const key of styleKeys) { const value = style[key as keyof typeof style]; - if (value == null || value === false) { + if (typeof value !== 'string') { continue; } - element.style.setProperty(key, String(value)); + element.style.setProperty(key, value); } // No cleanup: leave theme on container so reopening doesn't flash return undefined; diff --git a/specs/2026-07-31-sana-theme-optional-design.md b/specs/2026-07-31-sana-theme-optional-design.md index 7e6ec821fa..98466e68ed 100644 --- a/specs/2026-07-31-sana-theme-optional-design.md +++ b/specs/2026-07-31-sana-theme-optional-design.md @@ -15,7 +15,9 @@ Currently, teams using the Sana Canvas theme must pass `sanaCanvasProviderTheme` ### Simplified Default Setup -Teams using Sana Canvas globally will use a simpler setup: +"Using Sana Canvas globally" means setting `data-theme="sana-canvas"` on the `` element +(alongside importing the Sana CSS variables). Teams doing that can use a simpler setup — no +`theme` prop on `CanvasProvider`: ```tsx // New recommended setup - no theme prop needed From 96601dfd1a65c7cfccda0dd86fc962f5e453a4c6 Mon Sep 17 00:00:00 2001 From: "manuel.carrera" Date: Fri, 31 Jul 2026 12:59:27 -0600 Subject: [PATCH 18/19] fix: Remove file --- .../2026-07-31-sana-theme-optional-design.md | 140 ------------------ 1 file changed, 140 deletions(-) delete mode 100644 specs/2026-07-31-sana-theme-optional-design.md diff --git a/specs/2026-07-31-sana-theme-optional-design.md b/specs/2026-07-31-sana-theme-optional-design.md deleted file mode 100644 index 98466e68ed..0000000000 --- a/specs/2026-07-31-sana-theme-optional-design.md +++ /dev/null @@ -1,140 +0,0 @@ -# Making sanaCanvasProviderTheme Optional - Design Spec - -## Overview - -Currently, teams using the Sana Canvas theme must pass `sanaCanvasProviderTheme` to their root `CanvasProvider` to ensure popups inherit the correct theme. Since CSS variables naturally cascade through the DOM when `data-theme="sana-canvas"` is set on ``, this JavaScript forwarding is redundant for most use cases. - -## Goals - -- Simplify the default Sana Canvas theme setup -- Remove unnecessary configuration for teams -- Maintain backward compatibility -- Provide clear guidance on when the theme prop is still needed - -## Design - -### Simplified Default Setup - -"Using Sana Canvas globally" means setting `data-theme="sana-canvas"` on the `` element -(alongside importing the Sana CSS variables). Teams doing that can use a simpler setup — no -`theme` prop on `CanvasProvider`: - -```tsx -// New recommended setup - no theme prop needed -import {CanvasProvider} from '@workday/canvas-kit-react/common'; - - - - -``` - -The CSS setup remains unchanged: -```css -/* index.css */ -@import '@workday/canvas-tokens-web/css/sana/_variables.css'; -``` - -```html - -``` - -### When Theme Prop Is Still Needed - -The `sanaCanvasProviderTheme` remains available — and is **required for popup parity** — in these scenarios: - -1. **No access to ``**: Embedded apps, microfrontends, and third-party shells that cannot set - `data-theme="sana-canvas"` on ``. Nested `data-theme` does not reach portaled popups. - ```tsx - - - - ``` - -2. **Scoped Theming**: When a section needs different branding - ```tsx - - - - ``` - -3. **Testing**: When global CSS isn't loaded in test environments - -4. **Edge Cases**: Custom popup containers rendered outside normal document flow - -### Console Warning - -Add a development-only warning when `sanaCanvasProviderTheme` is used unnecessarily: - -```typescript -if (process.env.NODE_ENV !== 'production') { - if (theme === sanaCanvasProviderTheme && - document.documentElement.getAttribute('data-theme') === 'sana-canvas') { - console.warn( - 'Canvas Kit: You are passing sanaCanvasProviderTheme to CanvasProvider but ' + - 'data-theme="sana-canvas" is already set globally. The theme prop is not needed ' + - 'in this case and can be removed for simpler setup.' - ); - } -} -``` - -## Implementation Plan - -### 1. Documentation Updates - -Update the following files: -- `/modules/docs/llm/theming.md` - Remove requirement for sanaCanvasProviderTheme in global setup -- `/modules/react/common/lib/theming/README.md` - Clarify optional nature -- `/modules/react/common/lib/theming/sanaTheme.ts` - Update JSDoc comments -- `/modules/react/common/stories/mdx/Theming.mdx` - Show simplified setup as default - -### 2. Code Changes - -- Add console warning in CanvasProvider when theme is unnecessary -- Update TypeScript types/comments to indicate optional nature -- Ensure popup components properly inherit CSS variables without theme prop - -### 3. Migration Guide - -Add to v16 upgrade guide: -```markdown -## Simplified Sana Canvas Setup - -If you're using Sana Canvas globally with `data-theme="sana-canvas"`, you no longer need to pass -`sanaCanvasProviderTheme` to CanvasProvider: - -**Before:** -```tsx - - - -``` - -**After:** -```tsx - - - -``` - -The theme prop is now only needed for scoped theming scenarios. -``` - -### 4. Testing - -- Verify popups inherit Sana theme without provider theme prop -- Test scoped theming still works with theme prop -- Ensure console warning appears only when appropriate -- Confirm backward compatibility with existing implementations - -## Success Criteria - -- Teams can use Sana Canvas theme without any theme prop on CanvasProvider -- Popups (menus, modals, selects) correctly inherit global theme -- Documentation clearly explains when theme prop is needed -- No breaking changes for existing implementations -- Console warning helps teams simplify their setup - -## Timeline - -This is a non-breaking enhancement that simplifies the API. Implementation involves primarily documentation updates and adding a helpful console warning. \ No newline at end of file From 3a4c17bd3e83be82699c8f1668ec506f7b26ad1b Mon Sep 17 00:00:00 2001 From: "manuel.carrera" Date: Fri, 31 Jul 2026 13:28:18 -0600 Subject: [PATCH 19/19] docs: Update docs --- modules/docs/llm/theming.md | 14 +++++------ .../llm/upgrade-guides/16.0-UPGRADE-GUIDE.md | 10 ++++---- modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx | 10 ++++---- .../react/common/lib/theming/brandScope.ts | 24 +++++++++++++++++-- modules/react/common/lib/theming/sanaTheme.ts | 3 ++- modules/react/common/lib/theming/types.ts | 23 ++++++++++++++---- modules/react/common/spec/brandScope.spec.ts | 8 +++++++ .../react/common/spec/theming-types.spec.ts | 7 +++++- modules/react/common/stories/mdx/Theming.mdx | 14 +++++------ .../mdx/examples/SimplifiedSanaSetup.tsx | 5 ++++ .../mdx/examples/ThemingBrandScope.tsx | 7 +----- .../theming/ThemeComparison.stories.tsx | 2 +- .../theming/examples/BrandingFixture.tsx | 4 +++- modules/react/testing/lib/StaticStates.tsx | 2 +- .../stories/visualTesting.stories.tsx | 10 ++++++-- 15 files changed, 98 insertions(+), 45 deletions(-) diff --git a/modules/docs/llm/theming.md b/modules/docs/llm/theming.md index 12affce76b..266066eae5 100644 --- a/modules/docs/llm/theming.md +++ b/modules/docs/llm/theming.md @@ -100,8 +100,8 @@ import {base} from '@workday/canvas-tokens-web'; Popups (including menus, selects, modals, and toasts) portal to `document.body` — outside the parent component's DOM hierarchy. How theming reaches them: -**Preferred — you control ``:** set `data-theme="sana-canvas"` on ``. Popups inherit -Sana CSS variables automatically; no `theme` prop needed: +**Preferred — you control the document root:** set `data-theme="sana-canvas"` on the `` +element. Popups inherit Sana CSS variables automatically; no `theme` prop needed: ```tsx import {CanvasProvider} from '@workday/canvas-kit-react/common'; @@ -112,15 +112,15 @@ import {CanvasProvider} from '@workday/canvas-kit-react/common'; ``` -**Required — no access to ``:** embedded apps, microfrontends, and third-party shells often -cannot set attributes on ``. A nested `data-theme` on a wrapper does **not** apply to -portaled popups. Pass `sanaCanvasProviderTheme` so Canvas Kit forwards Sana brand variables onto -the popup stack container: +**Scoped / no document-root control:** if you cannot set `data-theme` on `` (embedded apps, +microfrontends, third-party shells), a nested `data-theme` alone does **not** reach portaled +popups. Pass both `data-theme="sana-canvas"` (for in-tree UI) and `sanaCanvasProviderTheme` (so +Canvas Kit forwards Sana brand variables onto the popup stack container): ```tsx import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; - + ``` diff --git a/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md b/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md index ec4200302a..1d1bebc92f 100644 --- a/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md +++ b/modules/docs/llm/upgrade-guides/16.0-UPGRADE-GUIDE.md @@ -133,15 +133,15 @@ import {CanvasProvider} from '@workday/canvas-kit-react/common'; ``` -**Still required — no access to ``:** embedded apps, microfrontends, and third-party shells -often cannot set attributes on ``. Nested `data-theme` does not reach portaled popups -(including all Canvas Kit popups such as menus, selects, modals, and toasts). Keep passing -`sanaCanvasProviderTheme` so those surfaces get Sana brand variables: +**Still required — cannot set `data-theme` on ``:** embedded apps, microfrontends, and +third-party shells often cannot set attributes on the document root. Nested `data-theme` does not +reach portaled popups (including all Canvas Kit popups such as menus, selects, modals, and toasts). +Pass both `data-theme="sana-canvas"` (in-tree UI) and `sanaCanvasProviderTheme` (popup forwarding): ```tsx import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; - + ``` diff --git a/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx b/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx index ccc90f1571..a39f31ec1a 100644 --- a/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx +++ b/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx @@ -138,15 +138,15 @@ import {CanvasProvider} from '@workday/canvas-kit-react/common'; ``` -**Still required — no access to ``:** embedded apps, microfrontends, and third-party shells -often cannot set attributes on ``. Nested `data-theme` does not reach portaled popups -(including all Canvas Kit popups such as menus, selects, modals, and toasts). Keep passing -`sanaCanvasProviderTheme` so those surfaces get Sana brand variables: +**Still required — cannot set `data-theme` on ``:** embedded apps, microfrontends, and +third-party shells often cannot set attributes on the document root. Nested `data-theme` does not +reach portaled popups (including all Canvas Kit popups such as menus, selects, modals, and toasts). +Pass both `data-theme="sana-canvas"` (in-tree UI) and `sanaCanvasProviderTheme` (popup forwarding): ```tsx import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; - + ``` diff --git a/modules/react/common/lib/theming/brandScope.ts b/modules/react/common/lib/theming/brandScope.ts index 65189c4c68..cdea668029 100644 --- a/modules/react/common/lib/theming/brandScope.ts +++ b/modules/react/common/lib/theming/brandScope.ts @@ -7,6 +7,7 @@ import {defaultCanvasTheme} from './theme'; import { CanvasActionBrandRamp, CanvasBrandRamp, + CanvasNeutralBrandRamp, CanvasNumericalBrandTheme, CanvasProviderTheme, CanvasTheme, @@ -84,14 +85,18 @@ const setStyleVar = (style: React.CSSProperties, token: string, value: string) = /** Maps a numerical brand ramp onto `brand.` CSS variables. */ export function writeNumericalBrandRamp( color: BrandColor | 'action', - ramp: CanvasBrandRamp | CanvasActionBrandRamp | undefined, + ramp: CanvasBrandRamp | CanvasNeutralBrandRamp | CanvasActionBrandRamp | undefined, style: React.CSSProperties, options?: {skipKeys?: Set} ) { if (!ramp) { return; } - (Object.keys(ramp) as Array).forEach(rampKey => { + ( + Object.keys(ramp) as Array< + keyof (CanvasBrandRamp | CanvasNeutralBrandRamp | CanvasActionBrandRamp) + > + ).forEach(rampKey => { if (options?.skipKeys?.has(String(rampKey))) { return; } @@ -224,6 +229,14 @@ export function applyPositiveBrandBundle(positiveColor: string, style: React.CSS } } +/** Called when consumer sets only `neutral.main` or `brand.neutral['600']`. */ +export function applyNeutralBrandBundle(neutralColor: string, style: React.CSSProperties) { + const value = maybeWrapCSSVariables(neutralColor); + + setStyleVar(style, brand.neutral.base, value); + setStyleVar(style, brand.neutral600, value); +} + /** Writes first-class selected shortcuts from numerical theme input. */ export function writeSelectedShortcuts( selected: CanvasNumericalBrandTheme['selected'] | undefined, @@ -509,6 +522,10 @@ export function writeNumericalTheme( applyPositiveBrandBundle(brandPalette.positive['600'], style); skipRamp.positive = new Set(['600']); } + if (brandPalette.neutral?.['600'] && Object.keys(brandPalette.neutral).length === 1) { + applyNeutralBrandBundle(brandPalette.neutral['600'], style); + skipRamp.neutral = new Set(['600']); + } } if (brandPalette) { @@ -542,5 +559,8 @@ export function writeBrandScopeSemantic( if (palette?.success?.main) { applyPositiveBrandBundle(palette.success.main, style); } + if (palette?.neutral?.main) { + applyNeutralBrandBundle(palette.neutral.main, style); + } writeIndependentBrandTokens(theme, style); } diff --git a/modules/react/common/lib/theming/sanaTheme.ts b/modules/react/common/lib/theming/sanaTheme.ts index a82060436c..2f96d9a4aa 100644 --- a/modules/react/common/lib/theming/sanaTheme.ts +++ b/modules/react/common/lib/theming/sanaTheme.ts @@ -37,7 +37,8 @@ const sanaBrandNeutral = { * Values are `var()` references to Sana brand variables — not merged from `defaultCanvasTheme`. */ export const sanaCanvasNumericalTheme: CanvasNumericalBrandTheme = { - themeScope: 'full', + // Explicit brand vars only — multi-key ramps write 1:1; no system shortcut bundles run. + themeScope: 'brand', brand: { action: { base: varRef(brand.neutral975), diff --git a/modules/react/common/lib/theming/types.ts b/modules/react/common/lib/theming/types.ts index e2bfee635d..df088c9e36 100644 --- a/modules/react/common/lib/theming/types.ts +++ b/modules/react/common/lib/theming/types.ts @@ -291,13 +291,14 @@ export type EmotionCanvasTheme = {canvas: CanvasTheme}; * - `'500'` — focus rings and border primary (independent of `'600'`) * - `'A50'` — selected surface tint (when not using `selected.surface`) * - `'25'` / `'A25'` — subtle brand surfaces + * + * Neutral-only Sana steps (`'150'`, `'850'`, `'A150'`) live on {@link CanvasNeutralBrandRamp}. */ export type CanvasBrandRamp = Partial< Record< | '25' | '50' | '100' - | '150' | '200' | '300' | '400' @@ -305,19 +306,24 @@ export type CanvasBrandRamp = Partial< | '600' | '700' | '800' - | '850' | '900' | '950' | '975' | 'A25' | 'A50' | 'A100' - | 'A150' | 'A200', string > >; +/** + * Neutral brand ramp — includes Sana-only steps (`150` / `850` / `A150`) that are not + * exported for primary/critical/caution/positive families. + */ +export type CanvasNeutralBrandRamp = CanvasBrandRamp & + Partial>; + /** Semantic keys for `brand.action.*` CSS variables (PrimaryButton, etc.). */ export type CanvasActionBrandRamp = Partial< Record< @@ -430,7 +436,7 @@ export interface CanvasNumericalBrandTheme { * Affects brand-neutral text, borders, and surfaces where components reference * `brand.neutral.*` or `system.color.brand` tokens tied to neutral. */ - neutral?: CanvasBrandRamp; + neutral?: CanvasNeutralBrandRamp; }; /** @@ -487,7 +493,14 @@ export function isNumericalTheme( if ('canvas' in theme) { return false; } - return 'brand' in theme || 'system' in theme || 'selected' in theme; + // `direction` / `themeScope` alone are valid numerical themes (e.g. RTL-only). + return ( + 'brand' in theme || + 'system' in theme || + 'selected' in theme || + 'direction' in theme || + 'themeScope' in theme + ); } const EXTENDED_RAMP_KEYS = new Set([ diff --git a/modules/react/common/spec/brandScope.spec.ts b/modules/react/common/spec/brandScope.spec.ts index c03aa8f171..67bf2a6931 100644 --- a/modules/react/common/spec/brandScope.spec.ts +++ b/modules/react/common/spec/brandScope.spec.ts @@ -80,6 +80,14 @@ describe('writeBrandScopeSemantic', () => { expect(style[system.color.brand.border.critical as string]).toBe('crimson'); expect(style[system.color.brand.border.caution as string]).toBe('coral'); }); + + it('applies neutral.main via the neutral brand bundle', () => { + const style: Record = {}; + writeBrandScopeSemantic({canvas: {palette: {neutral: {main: 'gray'}}}}, style); + + expect(style[brand.neutral.base as string]).toBe('gray'); + expect(style[brand.neutral600 as string]).toBe('gray'); + }); }); describe('writeNumericalTheme', () => { diff --git a/modules/react/common/spec/theming-types.spec.ts b/modules/react/common/spec/theming-types.spec.ts index 19d0ef0174..4fb011504f 100644 --- a/modules/react/common/spec/theming-types.spec.ts +++ b/modules/react/common/spec/theming-types.spec.ts @@ -1,4 +1,4 @@ -import {isNumericalTheme, resolveThemingScope} from '../lib/theming/types'; +import {ContentDirection, isNumericalTheme, resolveThemingScope} from '../lib/theming/types'; describe('isNumericalTheme', () => { it('is false for the deprecated shape', () => { @@ -9,6 +9,11 @@ describe('isNumericalTheme', () => { expect(isNumericalTheme({brand: {primary: {'600': 'red'}}})).toBe(true); }); + it('is true for direction-only and themeScope-only numerical themes', () => { + expect(isNumericalTheme({direction: ContentDirection.RTL})).toBe(true); + expect(isNumericalTheme({themeScope: 'brand'})).toBe(true); + }); + it('is false for undefined and empty', () => { expect(isNumericalTheme(undefined)).toBe(false); expect(isNumericalTheme({} as any)).toBe(false); diff --git a/modules/react/common/stories/mdx/Theming.mdx b/modules/react/common/stories/mdx/Theming.mdx index f48aeb44b3..4b96b680e5 100644 --- a/modules/react/common/stories/mdx/Theming.mdx +++ b/modules/react/common/stories/mdx/Theming.mdx @@ -106,8 +106,8 @@ import {base} from '@workday/canvas-tokens-web'; Popups (including menus, selects, modals, and toasts) portal to `document.body` — outside the parent component's DOM hierarchy. How theming reaches them: -**Preferred — you control ``:** set `data-theme="sana-canvas"` on ``. Popups inherit -Sana CSS variables automatically; no `theme` prop needed: +**Preferred — you control the document root:** set `data-theme="sana-canvas"` on the `` +element. Popups inherit Sana CSS variables automatically; no `theme` prop needed: ```tsx import {CanvasProvider} from '@workday/canvas-kit-react/common'; @@ -118,15 +118,13 @@ import {CanvasProvider} from '@workday/canvas-kit-react/common'; ``` -**Required — no access to ``:** embedded apps, microfrontends, and third-party shells often -cannot set attributes on ``. A nested `data-theme` on a wrapper does **not** apply to -portaled popups. Pass `sanaCanvasProviderTheme` so Canvas Kit forwards Sana brand variables onto -the popup stack container: +**Scoped / no document-root control:** if you cannot set `data-theme` on `` (embedded apps, +microfrontends, third-party shells), a nested `data-theme` alone does **not** reach portaled +popups. Pass both `data-theme="sana-canvas"` (for in-tree UI) and `sanaCanvasProviderTheme` (so +Canvas Kit forwards Sana brand variables onto the popup stack container): ```tsx import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; -// Make sure to import the Sana variables last! -import '@workday/canvas-tokens-web/css/sana/_variables.css'; diff --git a/modules/react/common/stories/mdx/examples/SimplifiedSanaSetup.tsx b/modules/react/common/stories/mdx/examples/SimplifiedSanaSetup.tsx index d9b11e50b7..11641a13ad 100644 --- a/modules/react/common/stories/mdx/examples/SimplifiedSanaSetup.tsx +++ b/modules/react/common/stories/mdx/examples/SimplifiedSanaSetup.tsx @@ -5,6 +5,11 @@ import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react import {Menu} from '@workday/canvas-kit-react/menu'; import {Popup, useCloseOnOutsideClick, usePopupModel} from '@workday/canvas-kit-react/popup'; +/** + * Scoped Sana setup for popup parity: `data-theme` themes the in-tree UI, and + * `sanaCanvasProviderTheme` forwards brand CSS variables onto portaled popups + * (menus, selects, modals) that render under `document.body`. + */ export const SimplifiedSetup = () => { const myModel = usePopupModel(); useCloseOnOutsideClick(myModel); diff --git a/modules/react/common/stories/mdx/examples/ThemingBrandScope.tsx b/modules/react/common/stories/mdx/examples/ThemingBrandScope.tsx index 13026588f1..c5f4a356f8 100644 --- a/modules/react/common/stories/mdx/examples/ThemingBrandScope.tsx +++ b/modules/react/common/stories/mdx/examples/ThemingBrandScope.tsx @@ -5,12 +5,7 @@ import {Menu} from '@workday/canvas-kit-react/menu'; import {base} from '@workday/canvas-tokens-web'; export const ThemingBrandScope = () => ( - + Brand scope diff --git a/modules/react/common/stories/theming/ThemeComparison.stories.tsx b/modules/react/common/stories/theming/ThemeComparison.stories.tsx index 4b03545d59..88ae74d5f8 100644 --- a/modules/react/common/stories/theming/ThemeComparison.stories.tsx +++ b/modules/react/common/stories/theming/ThemeComparison.stories.tsx @@ -35,7 +35,7 @@ export const SanaCanvas = { docs: { description: { story: - 'Default global Sana theme (`data-theme="sana-canvas"`). Compare with Canvas using `?theme=canvas` in the URL.', + 'Side-by-side branding fixtures. The second column intentionally uses scoped `sanaCanvasProviderTheme` (may log the global-theme console warning in Storybook). Compare with Canvas using `?theme=canvas` in the URL.', }, }, chromatic: {disable: false}, diff --git a/modules/react/common/stories/theming/examples/BrandingFixture.tsx b/modules/react/common/stories/theming/examples/BrandingFixture.tsx index a89f15673f..2c8b7d4347 100644 --- a/modules/react/common/stories/theming/examples/BrandingFixture.tsx +++ b/modules/react/common/stories/theming/examples/BrandingFixture.tsx @@ -43,7 +43,9 @@ export type BrandingFixtureProps = { const columnStyles = createStyles({ backgroundColor: system.color.bg.alt.default, - width: '100vw', + flex: '1 1 280px', + minWidth: '280px', + maxWidth: '100%', }); export const BrandingFixture = ({label, scopedTheme}: BrandingFixtureProps) => { diff --git a/modules/react/testing/lib/StaticStates.tsx b/modules/react/testing/lib/StaticStates.tsx index 32ea607fe2..fc05c63da6 100644 --- a/modules/react/testing/lib/StaticStates.tsx +++ b/modules/react/testing/lib/StaticStates.tsx @@ -50,7 +50,7 @@ export const StaticStates: React.FC< // Nest ThemeProvider *inside* CanvasProvider so CanvasProvider's own ThemeProvider // (used for legacy Emotion theme consumers) does not wipe `_styleRewriteFn`. return ( - + {children} ); diff --git a/modules/react/text-input/stories/visualTesting.stories.tsx b/modules/react/text-input/stories/visualTesting.stories.tsx index 3ac084a5c9..a3796e551e 100644 --- a/modules/react/text-input/stories/visualTesting.stories.tsx +++ b/modules/react/text-input/stories/visualTesting.stories.tsx @@ -1,5 +1,9 @@ import {TertiaryButton} from '@workday/canvas-kit-react/button'; -import {CanvasProvider, CanvasProviderTheme} from '@workday/canvas-kit-react/common'; +import { + CanvasProvider, + CanvasProviderTheme, + PartialCanvasTheme, +} from '@workday/canvas-kit-react/common'; import {SystemIcon} from '@workday/canvas-kit-react/icon'; import { ComponentStatesTable, @@ -13,6 +17,8 @@ import {system} from '@workday/canvas-tokens-web'; import {customColorTheme, toCanvasProviderTheme} from '../../../../utils/storybook'; +type VisualTestingTheme = PartialCanvasTheme | CanvasProviderTheme; + export default { title: 'Testing/Inputs/Text Input', component: TextInput, @@ -23,7 +29,7 @@ export default { }, }; -export const TextInputStates = ({theme}: {theme?: CanvasProviderTheme} = {}) => ( +export const TextInputStates = ({theme}: {theme?: VisualTestingTheme} = {}) => (