diff --git a/pages/theming/global-theme-iframes-inner.page.tsx b/pages/theming/global-theme-iframes-inner.page.tsx
new file mode 100644
index 0000000000..876550f393
--- /dev/null
+++ b/pages/theming/global-theme-iframes-inner.page.tsx
@@ -0,0 +1,23 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import React, { useEffect } from 'react';
+
+import { preset } from '~components/internal/generated/theming';
+import { applyGlobalTheme } from '~components/theming';
+
+const colorProperty = preset.propertiesMap.colorTextAccent;
+
+export default function GlobalThemeIframesContentPage() {
+ useEffect(() => {
+ applyGlobalTheme();
+ }, []);
+
+ return (
+
+
Inner iframe
+
+ Themed text
+
+
+ );
+}
diff --git a/pages/theming/global-theme-iframes.page.tsx b/pages/theming/global-theme-iframes.page.tsx
new file mode 100644
index 0000000000..ae3b35a323
--- /dev/null
+++ b/pages/theming/global-theme-iframes.page.tsx
@@ -0,0 +1,52 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import React, { useState } from 'react';
+
+import { setGlobalTheme, Theme } from '~components/theming';
+
+const themeA: Theme = {
+ tokens: {
+ colorTextAccent: '#ff0000',
+ },
+};
+
+const themeB: Theme = {
+ tokens: {
+ colorTextAccent: '#0000ff',
+ },
+};
+
+export default function GlobalThemeIframesPage() {
+ const [themeApplied, setThemeApplied] = useState(null);
+ const iframeHref = window.location.href.replace('global-theme-iframes', 'global-theme-iframes-inner');
+
+ return (
+
+
Global Theme with Iframes
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/theming/__integ__/global-theme-iframes.test.ts b/src/theming/__integ__/global-theme-iframes.test.ts
new file mode 100644
index 0000000000..bbbb7b6d7c
--- /dev/null
+++ b/src/theming/__integ__/global-theme-iframes.test.ts
@@ -0,0 +1,107 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects';
+import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';
+
+class GlobalThemeIframesPage extends BasePageObject {
+ async getAppliedColorInsideIframe(iframeSelector: string): Promise {
+ let color = '';
+ await this.runInsideIframe(iframeSelector, true, async () => {
+ await this.waitForVisible('[data-testid="themed-element"]');
+ color = await this.browser.execute(() => {
+ return getComputedStyle(document.querySelector('[data-testid="themed-element"]')!)
+ .getPropertyValue('color')
+ .trim();
+ });
+ });
+ return color;
+ }
+
+ async waitForIframeColor(iframeSelector: string, expectedColor: string): Promise {
+ await this.browser.waitUntil(
+ async () => {
+ const color = await this.getAppliedColorInsideIframe(iframeSelector);
+ return color === expectedColor;
+ },
+ { timeout: 5000, timeoutMsg: `Expected color "${expectedColor}" in ${iframeSelector} but did not match in time` }
+ );
+ }
+
+ setThemeA() {
+ return this.click('[data-testid="set-theme-a"]');
+ }
+
+ setThemeB() {
+ return this.click('[data-testid="set-theme-b"]');
+ }
+
+ getCurrentTheme(): Promise {
+ return this.getText('[data-testid="current-theme"]');
+ }
+
+ async waitForIframesDisplay(): Promise {
+ await this.runInsideIframe('#iframe-1', true, async () => {
+ await this.waitForVisible('[data-testid="themed-element"]');
+ });
+ await this.runInsideIframe('#iframe-2', true, async () => {
+ await this.waitForVisible('[data-testid="themed-element"]');
+ });
+ }
+}
+
+const setupTest = (testFn: (page: GlobalThemeIframesPage) => Promise) => {
+ return useBrowser(async browser => {
+ const page = new GlobalThemeIframesPage(browser);
+ await browser.url('#/light/theming/global-theme-iframes');
+ await page.waitForVisible('[data-testid="set-theme-a"]');
+ await page.waitForIframesDisplay();
+ await testFn(page);
+ });
+};
+
+describe('Global theme with multiple iframes', () => {
+ test(
+ 'applies theme to iframes after setGlobalTheme is called',
+ setupTest(async page => {
+ await page.setThemeA();
+
+ await page.waitForIframeColor('#iframe-1', 'rgb(255, 0, 0)');
+ await page.waitForIframeColor('#iframe-2', 'rgb(255, 0, 0)');
+ })
+ );
+
+ test(
+ 'propagates theme changes to all iframes',
+ setupTest(async page => {
+ await page.setThemeA();
+ await page.waitForIframeColor('#iframe-1', 'rgb(255, 0, 0)');
+ await page.waitForIframeColor('#iframe-2', 'rgb(255, 0, 0)');
+
+ await page.setThemeB();
+
+ await page.waitForIframeColor('#iframe-1', 'rgb(0, 0, 255)');
+ await page.waitForIframeColor('#iframe-2', 'rgb(0, 0, 255)');
+ })
+ );
+
+ test(
+ 'both iframes receive the same theme value',
+ setupTest(async page => {
+ await page.setThemeA();
+ await page.waitForIframeColor('#iframe-1', 'rgb(255, 0, 0)');
+ await page.waitForIframeColor('#iframe-2', 'rgb(255, 0, 0)');
+ })
+ );
+
+ test(
+ 'switching themes multiple times applies the latest theme',
+ setupTest(async page => {
+ await page.setThemeA();
+ await page.setThemeB();
+ await page.setThemeA();
+
+ await page.waitForIframeColor('#iframe-1', 'rgb(255, 0, 0)');
+ await page.waitForIframeColor('#iframe-2', 'rgb(255, 0, 0)');
+ })
+ );
+});
diff --git a/src/theming/__tests__/index-ssr.test.ts b/src/theming/__tests__/index-ssr.test.ts
new file mode 100644
index 0000000000..6865612b5b
--- /dev/null
+++ b/src/theming/__tests__/index-ssr.test.ts
@@ -0,0 +1,37 @@
+/**
+ * @jest-environment node
+ */
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import { applyGlobalTheme, setGlobalTheme, Theme } from '../../../lib/components/theming';
+
+const theme: Theme = {
+ tokens: {
+ colorTextAccent: {
+ light: 'red',
+ dark: 'orange',
+ },
+ },
+};
+
+test('window is not defined in this environment', () => {
+ expect(typeof window).toBe('undefined');
+});
+
+describe('setGlobalTheme', () => {
+ test('does not throw when window is undefined', () => {
+ expect(() => setGlobalTheme(theme)).not.toThrow();
+ });
+});
+
+describe('applyGlobalTheme', () => {
+ test('does not throw when window is undefined', () => {
+ expect(() => applyGlobalTheme()).not.toThrow();
+ });
+
+ test('returns a no-op reset function when window is undefined', () => {
+ const { reset } = applyGlobalTheme();
+ expect(reset).toBeInstanceOf(Function);
+ expect(() => reset()).not.toThrow();
+ });
+});
diff --git a/src/theming/__tests__/index.test.ts b/src/theming/__tests__/index.test.ts
index 2074cbb210..b22cabbd5c 100644
--- a/src/theming/__tests__/index.test.ts
+++ b/src/theming/__tests__/index.test.ts
@@ -1,8 +1,15 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
-import { applyTheme, generateThemeStylesheet, Theme } from '../../../lib/components/theming';
+import {
+ applyGlobalTheme,
+ applyTheme,
+ generateThemeStylesheet,
+ setGlobalTheme,
+ Theme,
+} from '../../../lib/components/theming';
const findStyleNode = () => document.head.querySelector('style');
+const findAllStyleNodes = () => document.head.querySelectorAll('style');
const attachNonceMetaElement = (nonce: string) => {
const metaNode = document.createElement('meta');
metaNode.name = 'nonce';
@@ -10,6 +17,8 @@ const attachNonceMetaElement = (nonce: string) => {
document.head.appendChild(metaNode);
};
+const themeStorageKey = Symbol.for('awsui-global-theme');
+
const theme: Theme = {
tokens: {
colorTextAccent: {
@@ -19,6 +28,21 @@ const theme: Theme = {
},
};
+const anotherTheme: Theme = {
+ tokens: {
+ colorTextAccent: {
+ light: 'blue',
+ dark: 'green',
+ },
+ },
+};
+
+afterEach(() => {
+ // Clean up global theme state between tests.
+ delete (window as any)[themeStorageKey];
+ document.head.querySelectorAll('style').forEach(node => node.remove());
+});
+
test('applyTheme appends and removes awsui style node', () => {
const { reset } = applyTheme({ theme });
@@ -41,3 +65,145 @@ test('applyTheme respects nonce meta elements', () => {
test('generateThemeStylesheet returns the theme override stylesheet as a string', () => {
expect(generateThemeStylesheet({ theme })).toEqual(expect.any(String));
});
+
+describe('setGlobalTheme', () => {
+ test('stores the theme on window using the well-known symbol', () => {
+ setGlobalTheme(theme);
+
+ expect((window as any)[themeStorageKey]).toBe(theme);
+ });
+
+ test('dispatches a change event on the top window', () => {
+ const listener = jest.fn();
+ window.addEventListener('awsui-global-theme-change', listener);
+
+ setGlobalTheme(theme);
+
+ expect(listener).toHaveBeenCalledTimes(1);
+
+ window.removeEventListener('awsui-global-theme-change', listener);
+ });
+
+ test('overwrites a previously set theme', () => {
+ setGlobalTheme(theme);
+ setGlobalTheme(anotherTheme);
+
+ expect((window as any)[themeStorageKey]).toBe(anotherTheme);
+ });
+});
+
+describe('applyGlobalTheme', () => {
+ let resetGlobalTheme: () => void;
+
+ afterEach(() => {
+ resetGlobalTheme();
+ });
+
+ test('applies the current global theme immediately', () => {
+ setGlobalTheme(theme);
+
+ ({ reset: resetGlobalTheme } = applyGlobalTheme());
+
+ expect(findStyleNode()).not.toBeNull();
+ });
+
+ test('does not apply a style node when no global theme is set', () => {
+ ({ reset: resetGlobalTheme } = applyGlobalTheme());
+
+ expect(findStyleNode()).toBeNull();
+ });
+
+ test('reacts to subsequent setGlobalTheme calls', () => {
+ ({ reset: resetGlobalTheme } = applyGlobalTheme());
+
+ expect(findStyleNode()).toBeNull();
+
+ setGlobalTheme(theme);
+
+ expect(findStyleNode()).not.toBeNull();
+ });
+
+ test('replaces the previous style node when the theme changes', () => {
+ ({ reset: resetGlobalTheme } = applyGlobalTheme());
+
+ setGlobalTheme(theme);
+ const firstContent = findStyleNode()?.textContent;
+
+ setGlobalTheme(anotherTheme);
+ const secondContent = findStyleNode()?.textContent;
+
+ // Only one style node should exist at a time.
+ expect(findAllStyleNodes()).toHaveLength(1);
+ // Content should differ between themes.
+ expect(firstContent).not.toEqual(secondContent);
+ });
+
+ test('reset removes the style node and stops listening', () => {
+ ({ reset: resetGlobalTheme } = applyGlobalTheme());
+
+ setGlobalTheme(theme);
+ expect(findStyleNode()).not.toBeNull();
+
+ resetGlobalTheme();
+
+ expect(findStyleNode()).toBeNull();
+
+ // Further theme changes should have no effect.
+ setGlobalTheme(anotherTheme);
+ expect(findStyleNode()).toBeNull();
+ });
+});
+
+describe('cross-origin iframe fallback', () => {
+ let originalTop: typeof window.top;
+
+ beforeEach(() => {
+ originalTop = window.top;
+ // Simulate cross-origin restriction: accessing window.top throws a SecurityError.
+ Object.defineProperty(window, 'top', {
+ get() {
+ throw new DOMException('Blocked a frame with origin from accessing a cross-origin frame.', 'SecurityError');
+ },
+ configurable: true,
+ });
+ });
+
+ afterEach(() => {
+ Object.defineProperty(window, 'top', {
+ value: originalTop,
+ configurable: true,
+ writable: true,
+ });
+ delete (window as any)[themeStorageKey];
+ document.head.querySelectorAll('style').forEach(node => node.remove());
+ });
+
+ test('setGlobalTheme falls back to current window when top is cross-origin', () => {
+ expect(() => setGlobalTheme(theme)).not.toThrow();
+ expect((window as any)[themeStorageKey]).toBe(theme);
+ });
+
+ test('applyGlobalTheme falls back to current window when top is cross-origin', () => {
+ const { reset } = applyGlobalTheme();
+
+ setGlobalTheme(theme);
+
+ expect(findStyleNode()).not.toBeNull();
+
+ reset();
+ });
+
+ test('theme changes propagate via current window events when top is cross-origin', () => {
+ const { reset } = applyGlobalTheme();
+
+ expect(findStyleNode()).toBeNull();
+
+ setGlobalTheme(theme);
+ expect(findStyleNode()).not.toBeNull();
+
+ setGlobalTheme(anotherTheme);
+ expect(findAllStyleNodes()).toHaveLength(1);
+
+ reset();
+ });
+});
diff --git a/src/theming/index.ts b/src/theming/index.ts
index 7037fb3fcc..748c52d860 100644
--- a/src/theming/index.ts
+++ b/src/theming/index.ts
@@ -38,3 +38,72 @@ export function generateThemeStylesheet({ theme, baseThemeId }: GenerateThemeSty
baseThemeId,
});
}
+
+const themeStorageKey = Symbol.for('awsui-global-theme');
+const themeChangeEvent = 'awsui-global-theme-change';
+
+interface WindowWithTheme extends Window {
+ [themeStorageKey]?: Theme;
+}
+
+function getTopWindow(): WindowWithTheme {
+ try {
+ if (window.top && window.top.document) {
+ return window.top as WindowWithTheme;
+ }
+ } catch {
+ // Cross-origin access error — fall back to current window.
+ }
+ return window as WindowWithTheme;
+}
+
+export function setGlobalTheme(theme: Theme): void {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ const topWindow = getTopWindow();
+ topWindow[themeStorageKey] = theme;
+ topWindow.dispatchEvent(new Event(themeChangeEvent));
+}
+
+function getGlobalTheme(): Theme | undefined {
+ const topWindow = getTopWindow();
+ return topWindow[themeStorageKey];
+}
+
+export function applyGlobalTheme(): ApplyThemeResult {
+ if (typeof window === 'undefined') {
+ return { reset: () => {} };
+ }
+
+ const topWindow = getTopWindow();
+ let currentReset: (() => void) | undefined;
+
+ function apply() {
+ currentReset?.();
+ currentReset = undefined;
+ const theme = getGlobalTheme();
+
+ if (theme) {
+ const result = applyTheme({ theme });
+ currentReset = result.reset;
+ }
+ }
+
+ // Apply the current global theme immediately.
+ apply();
+
+ // Listen for future global theme changes.
+ const listener = () => {
+ apply();
+ };
+ topWindow.addEventListener(themeChangeEvent, listener);
+
+ return {
+ reset: () => {
+ currentReset?.();
+ currentReset = undefined;
+ topWindow.removeEventListener(themeChangeEvent, listener);
+ },
+ };
+}