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 = (
+
-
+ {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
+
+
+
+
+ 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
+
+
+
+
+);
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}
+
+
+
+
+ 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';