diff --git a/.storybook/preview.js b/.storybook/preview.js index 6ceb8c1561..e948271815 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -6,8 +6,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/README.md b/README.md index e5bdeb7b4e..8c8387d0b5 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,13 @@ 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'; @@ -105,6 +111,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 4c649c9887..266066eae5 100644 --- a/modules/docs/llm/theming.md +++ b/modules/docs/llm/theming.md @@ -3,678 +3,134 @@ 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. +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. -**Note:** While we support theme overrides, we advise to use global theming via CSS Variables. +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). -## Sana Canvas Theme (v16+) +## Sana Canvas Theme -Canvas Kit v16 targets the Sana Canvas visual language. Enable it by importing the Sana token -stylesheet **after** the system variables, then setting the theme attribute on your root element: +Import the Sana variables **last** in your root CSS and set `data-theme="sana-canvas"` on ``. ```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'; @import '@workday/canvas-tokens-web/css/sana/_variables.css'; -``` - -```html - -``` - -The Sana stylesheet reassigns palette values, shape sizes, depth shadows, typography, and semantic -color tokens under `[data-theme="sana-canvas"]`. Consumers write plain `system.*` paths β€” values -change automatically. Do not use `system.sana.*` or `system.legacy.*` in application code. - -Load Sana Sans via `@workday/canvas-kit-react/fonts`. For the full current token inventory and -Sana-specific value overrides, see [v4.4 Token Reference](./tokens/v4/v4.4-token-reference.md). - -## 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. - -```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 - -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 - -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: - -```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-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); + /* Optional β€” override only if you have a custom brand color */ + --cnvs-brand-primary-600: var(--cnvs-base-palette-magenta-600); } ``` -> **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, }) - - - - +```html + ``` -### Theming Modals and Dialogs +## Classic Canvas (without Sana theme) -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. +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. -**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 - +```html + ``` -**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; -} +import {CanvasProvider} from '@workday/canvas-kit-react/common'; - //... 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`) +The sana stylesheet only defines `[data-theme="sana-canvas"]` overrides. Without that attribute, +those rules do not apply. -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); -``` - -## 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'; -@import '@workday/canvas-tokens-web/css/component/_variables.css'; - -: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); -} -``` +If your application **has** opted into Sana globally but one subsection needs classic Canvas branding, +use `defaultBranding` on a scoped `CanvasProvider`: ```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); -} -``` +import {CanvasProvider, defaultBranding} from '@workday/canvas-kit-react/common'; -### 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: +## Scoped Theming -```tsx -
- -
-``` +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. -> **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. +The `theme` prop accepts a numerical `brand` object. Each key maps 1:1 to a `--cnvs-brand-*` CSS +variable. -#### Using CSS Logical Properties +| 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 | -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: +**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 -const rtlButtonStyles = createStyles({ - ':dir(rtl)': { - svg: { - transform: 'rotate(180deg)', - }, - }, -}); -``` - -```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. +import {base} from '@workday/canvas-tokens-web'; -```tsx - - + + ``` -> **Note:** Doing the following **will create a cascade barrier**. Only use this method if you -> intentionally want to override the default theme. +Popups (including menus, selects, modals, and toasts) portal to `document.body` β€” outside the +parent component's DOM hierarchy. How theming reaches them: -## 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.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. +**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 -// Before: - - - +import {CanvasProvider} from '@workday/canvas-kit-react/common'; -// 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. +**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 -/* βœ… 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 +`sanaCanvasProviderTheme` is also useful in tests without global Sana CSS or with custom popup +hosts outside the normal document flow. -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 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. -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 0421374e60..c3cefd7b06 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,156 @@ 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 + +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 +> 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 `data-theme="sana-canvas"` on `` when you control the document root (preferred). Nested +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 + +``` + +> **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 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 + `--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 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 + +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 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). + +#### 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 can set `data-theme="sana-canvas"` on ``, you no longer need to pass +`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 +import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; + + + + +``` + +**After (when you control ``):** +```tsx +import {CanvasProvider} from '@workday/canvas-kit-react/common'; + +// + + + +``` + +**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'; + + + + +``` + +If you control `` and 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) - [Codemod Transformations for Icons](#codemod-transformations-for-icons) @@ -643,8 +789,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 b7843fc3e7..c49af4115c 100644 --- a/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx +++ b/modules/docs/mdx/16.0-UPGRADE-GUIDE.mdx @@ -10,10 +10,157 @@ 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 + +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 +> 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 `data-theme="sana-canvas"` on `` when you control the document root (preferred). Nested +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 + +``` + +> **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 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 + `--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 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 + +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 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). + +#### 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 can set `data-theme="sana-canvas"` on ``, you no longer need to pass +`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 +import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; + + + + +``` + +**After (when you control ``):** +```tsx +import {CanvasProvider} from '@workday/canvas-kit-react/common'; + +// + + + +``` + +**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'; + + + + +``` + +If you control `` and 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) - [Codemod Transformations for Icons](#codemod-transformations-for-icons) @@ -647,8 +794,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/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 9598e60c6a..4d53d6df26 100644 --- a/modules/react/common/lib/CanvasProvider.tsx +++ b/modules/react/common/lib/CanvasProvider.tsx @@ -1,117 +1,59 @@ 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'; +import {sanaCanvasProviderTheme} from './theming/sanaTheme'; + +/** + * 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. - * 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. */ - 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 +100,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,240 +138,136 @@ 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; - - (['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]) { - 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 => { - // 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]) { - 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, + themeScope, ...props }: CanvasProviderProps & React.HTMLAttributes) => { - const {className, ...elemProps} = useCanvasThemeToCssVars(theme, props); + // 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 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' + ); + } + } + }, [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); + + // 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('--') && typeof value === 'string') { + // @ts-ignore - CSS custom property key + merged[key] = value; + } + } + return merged; + }, [parentBrandStyle, style]); + + const emotionTheme = theme + ? isNumericalTheme(theme) + ? ({canvas: defaultCanvasTheme} as Theme) + : (theme as Theme) + : undefined; + const content = ( +
)} + style={style} + > + {children} +
+ ); + + const wrappedContent = ( + + {content} + + ); + return ( - -
)} - > - {children} -
-
+ {emotionTheme ? ( + {wrappedContent} + ) : ( + wrappedContent + )}
); }; diff --git a/modules/react/common/lib/theming/README.md b/modules/react/common/lib/theming/README.md index 42b4d327bc..17fff4743e 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,41 @@ 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'; + + + + +``` + +### Popups and `sanaCanvasProviderTheme` + +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) ### Setting RTL Direction diff --git a/modules/react/common/lib/theming/brandScope.ts b/modules/react/common/lib/theming/brandScope.ts new file mode 100644 index 0000000000..cdea668029 --- /dev/null +++ b/modules/react/common/lib/theming/brandScope.ts @@ -0,0 +1,566 @@ +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 { + CanvasActionBrandRamp, + CanvasBrandRamp, + CanvasNeutralBrandRamp, + 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', +}; + +/** 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); +}; + +/** Maps a numerical brand ramp onto `brand.` CSS variables. */ +export function writeNumericalBrandRamp( + color: BrandColor | 'action', + ramp: CanvasBrandRamp | CanvasNeutralBrandRamp | CanvasActionBrandRamp | undefined, + style: React.CSSProperties, + options?: {skipKeys?: Set} +) { + if (!ramp) { + return; + } + ( + Object.keys(ramp) as Array< + keyof (CanvasBrandRamp | CanvasNeutralBrandRamp | CanvasActionBrandRamp) + > + ).forEach(rampKey => { + if (options?.skipKeys?.has(String(rampKey))) { + return; + } + const value = ramp[rampKey as keyof typeof ramp]; + if (value == null) { + return; + } + const token = + color === 'action' + ? brand.action[rampKey as keyof typeof brand.action] + : // @ts-ignore - dynamic token lookup + (brand[`${color}${rampKey}`] ?? EXTENDED_BRAND_TOKEN_MAP[`${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); + } + + 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, selectedFg); + } + if (BRAND_SCOPE_PRIMARY_BUNDLE.selected.surface) { + setStyleVar(style, BRAND_SCOPE_PRIMARY_BUNDLE.selected.surface, selectedSurface); + } + + 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); +} + +/** 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); + } +} + +/** 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, + 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 per-family shortcuts; remaining keys write 1:1. */ +export function writeNumericalTheme( + theme: CanvasNumericalBrandTheme, + style: React.CSSProperties, + scope: 'brand' | 'full' +) { + 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']); + // 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?.['600'] && Object.keys(critical).length === 1) { + applyCriticalBrandBundle(critical['600'], style); + skipRamp.critical = new Set(['600']); + } + const caution = brandPalette.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); + 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) { + (['primary', 'critical', 'caution', 'positive', 'neutral', 'action'] as const).forEach( + color => { + writeNumericalBrandRamp(color, brandPalette[color], style, {skipKeys: skipRamp[color]}); + } + ); + } + + 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 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); + } + if (palette?.neutral?.main) { + applyNeutralBrandBundle(palette.neutral.main, style); + } + writeIndependentBrandTokens(theme, style); +} diff --git a/modules/react/common/lib/theming/index.ts b/modules/react/common/lib/theming/index.ts index 0277053d58..06bcd0e0c1 100644 --- a/modules/react/common/lib/theming/index.ts +++ b/modules/react/common/lib/theming/index.ts @@ -14,6 +14,7 @@ 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'; /** * @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..2f96d9a4aa --- /dev/null +++ b/modules/react/common/lib/theming/sanaTheme.ts @@ -0,0 +1,133 @@ +/** + * 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` when `` is unavailable | + */ +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`. + */ +export const sanaCanvasNumericalTheme: CanvasNumericalBrandTheme = { + // Explicit brand vars only β€” multi-key ramps write 1:1; no system shortcut bundles run. + themeScope: 'brand', + brand: { + action: { + base: varRef(brand.neutral975), + dark: varRef(brand.neutral950), + darkest: varRef(brand.neutral900), + accent: varRef(base.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(sanaBrandNeutral['150']), + '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(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(sanaBrandNeutral.A150), + 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 root `CanvasProvider` to forward Sana brand CSS variables onto popup containers + * (menus, selects, modals, tooltips). + * + * **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 + * // Preferred β€” control + * import '@workday/canvas-tokens-web/css/sana/_variables.css'; + * // + * + * + * // No access to β€” required for popup parity + * + * ``` + */ +export const sanaCanvasProviderTheme = sanaCanvasNumericalTheme; diff --git a/modules/react/common/lib/theming/types.ts b/modules/react/common/lib/theming/types.ts index 85bc504fe6..df088c9e36 100644 --- a/modules/react/common/lib/theming/types.ts +++ b/modules/react/common/lib/theming/types.ts @@ -281,3 +281,295 @@ 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 + * + * Neutral-only Sana steps (`'150'`, `'850'`, `'A150'`) live on {@link CanvasNeutralBrandRamp}. + */ +export type CanvasBrandRamp = Partial< + Record< + | '25' + | '50' + | '100' + | '200' + | '300' + | '400' + | '500' + | '600' + | '700' + | '800' + | '900' + | '950' + | '975' + | 'A25' + | 'A50' + | 'A100' + | '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< + 'base' | 'lightest' | 'lighter' | 'light' | 'dark' | 'darkest' | 'darker' | 'accent', + 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 when `` is unavailable and popups need Sana brand forwarding + */ +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?: CanvasActionBrandRamp; + + /** + * 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?: CanvasNeutralBrandRamp; + }; + + /** + * 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; + } + // `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([ + '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)) { + // `main` alone uses brand-scope bundles for every semantic palette color. + if (key === 'main') { + continue; + } + if (EXTENDED_RAMP_KEYS.has(key)) { + return 'full'; + } + } + } + + return 'brand'; +} diff --git a/modules/react/common/spec/CanvasProvider.spec.tsx b/modules/react/common/spec/CanvasProvider.spec.tsx new file mode 100644 index 0000000000..487feeb115 --- /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').mockImplementation(() => {}); + + // 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').mockImplementation(() => {}); + + render( + +
Test
+
+ ); + + expect(consoleSpy).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should not warn when using a different theme', () => { + const consoleSpy = vi.spyOn(global.console, 'warn').mockImplementation(() => {}); + + document.documentElement.setAttribute('data-theme', 'sana-canvas'); + + render( + +
Test
+
+ ); + + expect(consoleSpy).not.toHaveBeenCalled(); + + document.documentElement.removeAttribute('data-theme'); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/modules/react/common/spec/brandScope.spec.ts b/modules/react/common/spec/brandScope.spec.ts new file mode 100644 index 0000000000..67bf2a6931 --- /dev/null +++ b/modules/react/common/spec/brandScope.spec.ts @@ -0,0 +1,154 @@ +import {brand, system} from '@workday/canvas-tokens-web'; + +import { + applyCautionBrandBundle, + applyCriticalBrandBundle, + applyPrimaryBrandBundle, + writeBrandScopeSemantic, + writeIndependentBrandTokens, + writeNumericalTheme, +} 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]).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(); + }); +}); + +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'); + }); +}); + +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'); + }); + + 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', () => { + 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'); + }); + + 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/common/spec/sanaTheme.spec.ts b/modules/react/common/spec/sanaTheme.spec.ts new file mode 100644 index 0000000000..7166c5ccf5 --- /dev/null +++ b/modules/react/common/spec/sanaTheme.spec.ts @@ -0,0 +1,30 @@ +import {base, 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?.action?.accent).toBe(`var(${base.neutral0})`); + 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})`); + }); + + 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 new file mode 100644 index 0000000000..4fb011504f --- /dev/null +++ b/modules/react/common/spec/theming-types.spec.ts @@ -0,0 +1,53 @@ +import {ContentDirection, isNumericalTheme, 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 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); + }); +}); + +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' + ); + }); + + 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 new file mode 100644 index 0000000000..84c70eec41 --- /dev/null +++ b/modules/react/common/spec/useCanvasThemeToCssVars.spec.tsx @@ -0,0 +1,63 @@ +import {renderHook} from '@testing-library/react'; + +import {brand, system} from '@workday/canvas-tokens-web'; + +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', () => { + 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'); + }); + + 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/common/stories/mdx/Theming.mdx b/modules/react/common/stories/mdx/Theming.mdx index e4b4bfaa68..4b96b680e5 100644 --- a/modules/react/common/stories/mdx/Theming.mdx +++ b/modules/react/common/stories/mdx/Theming.mdx @@ -2,499 +2,143 @@ 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'; +import {SimplifiedSetup} from './examples/SimplifiedSanaSetup'; # 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: - -```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 -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. - -### 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. +Import the Sana variables **last** in your root CSS and set `data-theme="sana-canvas"` on ``. ```css +/* index.css β€” order matters */ @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); -} -``` - -### 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. - - - -``` - -> **⚠️ 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 - -For all other cases, use global theming at `:root` to ensure consistent theming throughout your -application. - -## βœ… 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'; +@import '@workday/canvas-tokens-web/css/system/_variables.css'; +@import '@workday/canvas-tokens-web/css/sana/_variables.css'; :root { - /* Override brand primary colors */ + /* Optional β€” override only if you have a custom brand color */ --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. - - -; +```html + ``` -## CSS Token Structure - -Canvas Kit provides three layers of CSS variables. +## Classic Canvas (without Sana theme) -### Base Tokens (`base/_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. -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-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); -} +```html + ``` -### 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 +The sana stylesheet only defines `[data-theme="sana-canvas"]` overrides. Without that attribute, +those rules do not apply. -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. +If your application **has** opted into Sana globally but one subsection needs classic Canvas branding, +use `defaultBranding` on a scoped `CanvasProvider`: ```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. +## 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. +**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 -// 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. +import {CanvasProvider} from '@workday/canvas-kit-react/common'; +import {base} from '@workday/canvas-tokens-web'; -```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. +Popups (including menus, selects, modals, and toasts) portal to `document.body` β€” outside the +parent component's DOM hierarchy. How theming reaches them: -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. +**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 -/* βœ… 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. +**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 {base, brand, system} from '@workday/canvas-tokens-web'; +import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; -// Check token availability in development -console.log(brand.primary.base); // Should output CSS variable name + + + ``` -## Conclusion +`sanaCanvasProviderTheme` is also useful in tests without global Sana CSS or with custom popup +hosts outside the normal document flow. -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). +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/common/stories/mdx/examples/SimplifiedSanaSetup.tsx b/modules/react/common/stories/mdx/examples/SimplifiedSanaSetup.tsx new file mode 100644 index 0000000000..11641a13ad --- /dev/null +++ b/modules/react/common/stories/mdx/examples/SimplifiedSanaSetup.tsx @@ -0,0 +1,35 @@ +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'; + +/** + * 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); + return ( + + + Open Menu + + + + + Option 1 + Option 2 + Option 3 + + Hello World + + + + + + ); +}; 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..c5f4a356f8 --- /dev/null +++ b/modules/react/common/stories/mdx/examples/ThemingBrandScope.tsx @@ -0,0 +1,29 @@ +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..88ae74d5f8 --- /dev/null +++ b/modules/react/common/stories/theming/ThemeComparison.stories.tsx @@ -0,0 +1,44 @@ +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: + '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}, + }, + 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..2c8b7d4347 --- /dev/null +++ b/modules/react/common/stories/theming/examples/BrandingFixture.tsx @@ -0,0 +1,216 @@ +import * as React from 'react'; + +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 {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 {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 = { + label?: string; + scopedTheme?: CanvasProviderTheme; +}; + +const columnStyles = createStyles({ + backgroundColor: system.color.bg.alt.default, + flex: '1 1 280px', + minWidth: '280px', + maxWidth: '100%', +}); + +export const BrandingFixture = ({label, scopedTheme}: BrandingFixtureProps) => { + const 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 + + + + + + + + + ); + + if (scopedTheme) { + return {content}; + } + + return content; +}; diff --git a/modules/react/popup/lib/hooks/usePopupStack.ts b/modules/react/popup/lib/hooks/usePopupStack.ts index 19c477dc99..5a8efbfebd 100644 --- a/modules/react/popup/lib/hooks/usePopupStack.ts +++ b/modules/react/popup/lib/hooks/usePopupStack.ts @@ -1,8 +1,7 @@ -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 {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. @@ -52,8 +51,8 @@ export const usePopupStack = ( ): React.RefObject => { const {elementRef, localRef} = useLocalRef(ref); - const theme = React.useContext(ThemeContext as React.Context); - const {style} = useCanvasThemeToCssVars(theme, {}); + // 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. @@ -67,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 (typeof value !== 'string') { + continue; + } + element.style.setProperty(key, 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 new file mode 100644 index 0000000000..864277ee35 --- /dev/null +++ b/modules/react/popup/spec/usePopupStack.spec.tsx @@ -0,0 +1,224 @@ +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 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: { + 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 c0d4bad604..fc05c63da6 100644 --- a/modules/react/testing/lib/StaticStates.tsx +++ b/modules/react/testing/lib/StaticStates.tsx @@ -1,10 +1,13 @@ +import {ThemeProvider} from '@emotion/react'; 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'; @@ -34,22 +37,21 @@ 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; + // Nest ThemeProvider *inside* CanvasProvider so CanvasProvider's own ThemeProvider + // (used for legacy Emotion theme consumers) does not wipe `_styleRewriteFn`. return ( - - {children} + + {children} ); }; 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..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} 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, @@ -11,7 +15,9 @@ 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'; + +type VisualTestingTheme = PartialCanvasTheme | CanvasProviderTheme; export default { title: 'Testing/Inputs/Text Input', @@ -23,8 +29,8 @@ export default { }, }; -export const TextInputStates = () => ( - +export const TextInputStates = ({theme}: {theme?: VisualTestingTheme} = {}) => ( + ( ); -export const TextInputThemedStates = () => ; -TextInputThemedStates.parameters = { - canvasProviderDecorator: { - theme: customColorTheme, - }, -}; +export const TextInputThemedStates = () => ; export const InputGroupStates = () => ( 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/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..146560f7c0 100644 --- a/utils/storybook/customThemes.ts +++ b/utils/storybook/customThemes.ts @@ -1,4 +1,26 @@ -import {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: { @@ -23,3 +45,55 @@ export const customColorTheme: PartialCanvasTheme = { }, }, }; + +/** Brand-scope preset: primary only → buttons + selected states */ +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: { + palette: { + primary: {main: base.magenta600}, + common: {focusOutline: base.teal500}, + }, + }, +}; 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';