diff --git a/packages/apollo-core/build-tokens.js b/packages/apollo-core/build-tokens.js index 9e5aaa519..62124602a 100644 --- a/packages/apollo-core/build-tokens.js +++ b/packages/apollo-core/build-tokens.js @@ -31,6 +31,30 @@ StyleDictionary.registerFormat({ formatter: _.template(fs.readFileSync(path.join(templateDir, 'css-theme-variables.template'))), }); +// `css/variables` emits base tokens (palette, fonts, radius) under bare `:root {}`, +// which never matches inside a shadow root — so shadow-DOM consumers (traceview WC, +// ap-chat) fall back to Tailwind defaults. Adding `:host` scopes the tokens to the +// shadow host too (inherited through the tree); `:root` still covers the light DOM. +// Delegating to the built-in formatter keeps the token body byte-identical. +StyleDictionary.registerFormat({ + name: 'css/variables-shadow-host', + formatter: function (dictionary, platform) { + const css = StyleDictionary.format['css/variables'].call(this, dictionary, platform); + const scoped = css.replace(':root {', ':root, :host {'); + // Fail loud if the selector didn't change: a silent no-op (e.g. if a future + // Style Dictionary version emits `:root{` or reformats the block) would + // regenerate `variables.css` back to bare `:root {}` and reintroduce the + // shadow-DOM token-scoping bug this format exists to fix, with no signal. + if (scoped === css) { + throw new Error( + "css/variables-shadow-host: expected ':root {' in the built-in css/variables output but found none — " + + 'the selector was not rewritten to :root, :host. Shadow-DOM base tokens would be lost.' + ); + } + return scoped; + }, +}); + StyleDictionary.registerFormat({ name: 'scss/scoped-theme-variables', formatter: _.template(fs.readFileSync(path.join(templateDir, 'scoped-theme-variables.template'))), diff --git a/packages/apollo-core/src/tokens/config.js b/packages/apollo-core/src/tokens/config.js index 8c17ecd59..fceb28555 100644 --- a/packages/apollo-core/src/tokens/config.js +++ b/packages/apollo-core/src/tokens/config.js @@ -76,7 +76,7 @@ module.exports = { buildPath: 'src/tokens/css/', files: [ { - format: 'css/variables', + format: 'css/variables-shadow-host', destination: 'variables.css', options: { showFileHeader: false, diff --git a/web-packages/ap-chat/src/__tests__/ap-chat-element.test.ts b/web-packages/ap-chat/src/__tests__/ap-chat-element.test.ts index 950aaf839..bfc951d94 100644 --- a/web-packages/ap-chat/src/__tests__/ap-chat-element.test.ts +++ b/web-packages/ap-chat/src/__tests__/ap-chat-element.test.ts @@ -439,6 +439,27 @@ describe('ApChat Web Component', () => { expect(portalContainerAfter).toBe(portalContainer); }); + it('should carry the theme class on the portal container and keep it in sync', () => { + element.chatServiceInstance = mockService; + element.connectedCallback(); + + const shadowRoot = element.shadowRoot; + const portalContainer = shadowRoot?.querySelector('.portal-container') as HTMLDivElement; + expect(portalContainer).not.toBeNull(); + + // theme-variables.css activates semantic tokens for the portal subtree via + // selectors like `:where(.light)`, so the portal container must carry the + // theme class initially and update when the theme changes. + expect(portalContainer.classList.contains('light')).toBe(true); // default theme + + element.theme = 'dark'; + + expect(portalContainer.classList.contains('dark')).toBe(true); + expect(portalContainer.classList.contains('light')).toBe(false); + // The base `portal-container` class is preserved across theme changes. + expect(portalContainer.classList.contains('portal-container')).toBe(true); + }); + it('should maintain portal container across locale changes', () => { element.chatServiceInstance = mockService; element.connectedCallback(); diff --git a/web-packages/ap-chat/src/ap-chat-element.ts b/web-packages/ap-chat/src/ap-chat-element.ts index d778014d9..a2969a658 100644 --- a/web-packages/ap-chat/src/ap-chat-element.ts +++ b/web-packages/ap-chat/src/ap-chat-element.ts @@ -2,14 +2,12 @@ // These load into document and are available to Shadow DOM import '@uipath/apollo-react/core/fonts/font.css'; -import { createElement } from 'react'; - -import { createRoot, type Root } from 'react-dom/client'; - // Import Apollo CSS variables as raw strings for Shadow DOM injection // Using ?raw query parameter to import CSS as text instead of injecting globally import apolloThemeVariablesCSS from '@uipath/apollo-react/core/tokens/css/theme-variables.css?raw'; import apolloVariablesCSS from '@uipath/apollo-react/core/tokens/css/variables.css?raw'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; import { cleanupReactRenderer, createReactRenderer } from './react-renderer'; import { AutopilotChatEvent, type AutopilotChatService } from './service'; @@ -245,24 +243,18 @@ export class ApChat extends HTMLElement { } `; - // Create a constructable stylesheet for Shadow DOM - // Include Apollo design tokens, font faces, and icon styles - // Transform CSS selectors to work in Shadow DOM context - const transformedVariablesCSS = apolloVariablesCSS.replace(/:root\s*\{/g, ':host {'); // Transform :root to :host for Shadow DOM - - // For theme variables, apply to both :host and all children to ensure proper cascading - // This ensures theme variables are available everywhere in the shadow tree - const transformedThemeVariablesCSS = apolloThemeVariablesCSS - .replace(/:root\s*\{/g, ':host {') - .replace( - /body\.(light|dark|light-hc|dark-hc)(?:,\s*\.apollo-design\.\1)?\s*\{/g, - ':host(.$1), :host(.$1) * {' - ); // Apply to host and all descendants - + // Adopt Apollo design tokens, font faces, and icon styles into the Shadow DOM. + // No selector rewriting is needed: + // - `variables.css` base tokens (palette, fonts, radius) are scoped to + // `:root, :host` by apollo-core, so `:host` sets them on this element and + // they inherit through the entire shadow tree (including the portal). + // - `theme-variables.css` semantic tokens match any themed element via + // `:where(.light:not(.react-flow))`, which covers the container and portal + // container below (both carry the theme class). const shadowStyleSheet = new CSSStyleSheet(); const allStyles = [ - transformedVariablesCSS, - transformedThemeVariablesCSS, + apolloVariablesCSS, + apolloThemeVariablesCSS, ...fontFaceRules, iconStylesText, ].join('\n'); @@ -288,7 +280,9 @@ export class ApChat extends HTMLElement { `; this.shadowRoot.appendChild(hostStyles); - // Apply theme class to host element for :host(.theme) selectors + // Theme class on the host is a convenience hook for external styling; apollo + // tokens no longer depend on it (base via :host, semantic via the themed + // container/portal below). this.className = this._theme || 'light'; // Create container for React @@ -300,9 +294,9 @@ export class ApChat extends HTMLElement { this.shadowRoot.appendChild(this.container); // Create dedicated portal container for tooltips/popovers at Shadow DOM root - // This prevents clipping in embedded mode while being a real HTMLElement for MUI + // This prevents clipping in embedded mode while being a real HTMLElement for MUI. this.portalContainer = document.createElement('div'); - this.portalContainer.className = 'portal-container'; + this.portalContainer.className = `portal-container ${this._theme || 'light'}`; this.portalContainer.style.position = 'fixed'; this.portalContainer.style.top = '0'; this.portalContainer.style.left = '0'; @@ -373,10 +367,13 @@ export class ApChat extends HTMLElement { // Update theme class on both host element and container const theme = this._theme || 'light'; - this.className = theme; // For :host(.theme) selectors in Shadow DOM + this.className = theme; if (this.container) { this.container.className = theme; } + if (this.portalContainer) { + this.portalContainer.className = `portal-container ${theme}`; + } const props: ApChatProperties = { chatServiceInstance: this._chatServiceInstance,