diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/PublishedParagraph.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/pages/PublishedParagraph.tsx
index 2d48314d5c1..04ea1024b78 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/pages/PublishedParagraph.tsx
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/PublishedParagraph.tsx
@@ -11,30 +11,26 @@
*/
import { createRoot } from 'react-dom/client';
-import { ConfigProvider } from 'antd';
import { Empty } from '@/components';
import { SingleResultRenderer } from '@/templates';
+import { ZeppelinThemeProvider } from '@/theme';
import type { ParagraphConfigResults, ParagraphIResultsMsgItem } from '@zeppelin/sdk';
+const RESULT_FONT_FAMILY = "'Lucida Console', Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace";
+
export interface PublishedParagraphProps {
paragraphId: string;
results?: ParagraphIResultsMsgItem[];
config?: ParagraphConfigResults;
}
-export const PublishedParagraph = ({ results, config }: PublishedParagraphProps) => {
- if (!results || results.length === 0) {
- return
;
- }
-
- return (
-
+export const PublishedParagraph = ({ results, config }: PublishedParagraphProps) => (
+ // The empty state is inside the provider too: antd's Empty illustration is
+ // themed, so leaving it outside would leak a light widget into a dark page.
+
+ {!results || results.length === 0 ? (
+
+ ) : (
{results.map((result, index) => (
@@ -42,9 +38,9 @@ export const PublishedParagraph = ({ results, config }: PublishedParagraphProps)
))}
-
- );
-};
+ )}
+
+);
export const mount = (element: HTMLElement, props?: PublishedParagraphProps) => {
if (!element) {
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.spec.tsx
new file mode 100644
index 00000000000..d70daee60a2
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.spec.tsx
@@ -0,0 +1,93 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { act } from 'react';
+import { render, screen } from '@testing-library/react';
+import { theme as antdTheme } from 'antd';
+import { afterEach, describe, expect, it } from 'vitest';
+import { useHostThemeMode, ZeppelinThemeProvider } from './ZeppelinThemeProvider';
+import { HostThemeMode } from './hostTheme';
+
+const Probe = () => {
+ const { token } = antdTheme.useToken();
+ return (
+ <>
+
{token.colorBgContainer}
+
{token.fontFamily}
+
{useHostThemeMode()}
+ >
+ );
+};
+
+const setHostTheme = (mode: HostThemeMode) => {
+ document.documentElement.setAttribute('data-theme', mode);
+};
+
+describe('ZeppelinThemeProvider', () => {
+ afterEach(() => {
+ document.documentElement.removeAttribute('data-theme');
+ });
+
+ it('builds antd tokens from the dark algorithm when the shell is dark', () => {
+ setHostTheme('dark');
+ render(
+
+
+
+ );
+
+ // Light tokens would put a white container on the shell's dark page; today
+ // that only goes unnoticed because the shell's global .ant-* rules cover it.
+ expect(screen.getByTestId('container-bg').textContent).toBe('#141414');
+ expect(screen.getByTestId('mode').textContent).toBe('dark');
+ });
+
+ it('builds antd tokens from the default algorithm when the shell is light', () => {
+ setHostTheme('light');
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId('container-bg').textContent).toBe('#ffffff');
+ expect(screen.getByTestId('mode').textContent).toBe('light');
+ });
+
+ it('re-themes in place when the shell toggles the theme', async () => {
+ setHostTheme('light');
+ render(
+
+
+
+ );
+ expect(screen.getByTestId('container-bg').textContent).toBe('#ffffff');
+
+ await act(async () => {
+ setHostTheme('dark');
+ });
+
+ expect(screen.getByTestId('container-bg').textContent).toBe('#141414');
+ });
+
+ it('keeps surface tokens while switching algorithms', () => {
+ setHostTheme('dark');
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId('font').textContent).toBe('Consolas');
+ expect(screen.getByTestId('container-bg').textContent).toBe('#141414');
+ });
+});
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.tsx
new file mode 100644
index 00000000000..1ee2181aaae
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.tsx
@@ -0,0 +1,47 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createContext, ReactNode, useContext } from 'react';
+import { ConfigProvider, theme as antdTheme, ThemeConfig } from 'antd';
+import { HostThemeMode, useHostTheme } from './hostTheme';
+
+const HostThemeContext = createContext
('light');
+
+/** Resolved host theme for code that draws outside antd, such as canvas charts. */
+export const useHostThemeMode = (): HostThemeMode => useContext(HostThemeContext);
+
+export interface ZeppelinThemeProviderProps {
+ children: ReactNode;
+ /** Extra tokens for a single surface, e.g. a monospace result font. */
+ token?: ThemeConfig['token'];
+}
+
+/**
+ * Every exposed module should render inside this provider. Without it antd
+ * builds its styles from the default (light) algorithm, and the remote looks
+ * dark only for as long as the shell's global `.ant-*` rules happen to cover
+ * the components in use.
+ */
+export const ZeppelinThemeProvider = ({ children, token }: ZeppelinThemeProviderProps) => {
+ const mode = useHostTheme();
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/theme/chartTheme.spec.ts b/zeppelin-web-angular/projects/zeppelin-react/src/theme/chartTheme.spec.ts
new file mode 100644
index 00000000000..973bacdcdad
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/theme/chartTheme.spec.ts
@@ -0,0 +1,51 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { applyChartTheme, CHART_THEME } from './chartTheme';
+
+// chart.js ships '#666' text and 'rgba(0, 0, 0, 0.1)' grid lines, both of
+// which are meant for a light canvas.
+const chartJsDefaults = () => ({ defaults: { color: '#666', borderColor: 'rgba(0, 0, 0, 0.1)' } });
+
+describe('applyChartTheme', () => {
+ it('replaces the chart.js defaults with the dark palette', () => {
+ const chart = chartJsDefaults();
+
+ applyChartTheme(chart, 'dark');
+
+ expect(chart.defaults.color).toBe(CHART_THEME.dark.text);
+ expect(chart.defaults.borderColor).toBe(CHART_THEME.dark.grid);
+ });
+
+ it('replaces the chart.js defaults with the light palette', () => {
+ const chart = chartJsDefaults();
+
+ applyChartTheme(chart, 'light');
+
+ expect(chart.defaults.color).toBe(CHART_THEME.light.text);
+ expect(chart.defaults.borderColor).toBe(CHART_THEME.light.grid);
+ });
+
+ it('leaves no chart.js default in place for either mode', () => {
+ // The point of the issue: axis labels at '#666' sit at about 3.2:1 against
+ // the shell's dark background, below the 4.5:1 the rest of the UI meets.
+ const untouched = chartJsDefaults().defaults;
+
+ for (const mode of ['light', 'dark'] as const) {
+ const chart = chartJsDefaults();
+ applyChartTheme(chart, mode);
+ expect(chart.defaults.color).not.toBe(untouched.color);
+ expect(chart.defaults.borderColor).not.toBe(untouched.borderColor);
+ }
+ });
+});
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/theme/chartTheme.ts b/zeppelin-web-angular/projects/zeppelin-react/src/theme/chartTheme.ts
new file mode 100644
index 00000000000..01b563f95c2
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/theme/chartTheme.ts
@@ -0,0 +1,37 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { HostThemeMode } from './hostTheme';
+
+/**
+ * Charts are painted on a canvas, so no stylesheet reaches them. chart.js
+ * defaults to '#666' text and 'rgba(0, 0, 0, 0.1)' grid lines, which leaves
+ * axis labels at roughly 3.2:1 against the dark background and the grid
+ * invisible. These values follow antd's secondary text and split tokens.
+ */
+export const CHART_THEME: Record = {
+ light: { text: 'rgba(0, 0, 0, 0.65)', grid: 'rgba(0, 0, 0, 0.06)' },
+ dark: { text: 'rgba(255, 255, 255, 0.65)', grid: 'rgba(255, 255, 255, 0.12)' }
+};
+
+/** The two globals chart.js resolves ticks, legend labels and grid lines from. */
+export interface ChartThemeTarget {
+ defaults: {
+ color: unknown;
+ borderColor: unknown;
+ };
+}
+
+export const applyChartTheme = (chart: ChartThemeTarget, mode: HostThemeMode): void => {
+ chart.defaults.color = CHART_THEME[mode].text;
+ chart.defaults.borderColor = CHART_THEME[mode].grid;
+};
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/theme/hostTheme.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/theme/hostTheme.spec.tsx
new file mode 100644
index 00000000000..4661daff283
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/theme/hostTheme.spec.tsx
@@ -0,0 +1,122 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { act } from 'react';
+import { render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+import { HostThemeMode, readHostTheme, useHostTheme } from './hostTheme';
+
+const Probe = () => {useHostTheme()};
+
+const setHostTheme = (mode: HostThemeMode) => {
+ document.documentElement.setAttribute('data-theme', mode);
+ document.documentElement.classList.remove('light', 'dark');
+ document.documentElement.classList.add(mode);
+};
+
+const stubMatchMedia = (matches: boolean) => {
+ const listeners = new Set<() => void>();
+ const mql = {
+ matches,
+ addEventListener: (_: string, cb: () => void) => listeners.add(cb),
+ removeEventListener: (_: string, cb: () => void) => listeners.delete(cb)
+ };
+ (window as unknown as { matchMedia?: unknown }).matchMedia = () => mql;
+ return {
+ set: (next: boolean) => {
+ mql.matches = next;
+ listeners.forEach(cb => cb());
+ },
+ listenerCount: () => listeners.size
+ };
+};
+
+describe('readHostTheme', () => {
+ afterEach(() => {
+ document.documentElement.removeAttribute('data-theme');
+ document.documentElement.classList.remove('light', 'dark');
+ delete (window as unknown as { matchMedia?: unknown }).matchMedia;
+ });
+
+ it('reads the theme the shell writes to the document root', () => {
+ setHostTheme('dark');
+ expect(readHostTheme()).toBe('dark');
+
+ setHostTheme('light');
+ expect(readHostTheme()).toBe('light');
+ });
+
+ it('falls back to the root class when the attribute is missing', () => {
+ document.documentElement.classList.add('dark');
+ expect(readHostTheme()).toBe('dark');
+ });
+
+ it('falls back to the OS preference when the shell declares nothing', () => {
+ stubMatchMedia(true);
+ expect(readHostTheme()).toBe('dark');
+ });
+
+ it('defaults to light when neither the shell nor matchMedia is available', () => {
+ expect(readHostTheme()).toBe('light');
+ });
+});
+
+describe('useHostTheme', () => {
+ afterEach(() => {
+ document.documentElement.removeAttribute('data-theme');
+ document.documentElement.classList.remove('light', 'dark');
+ delete (window as unknown as { matchMedia?: unknown }).matchMedia;
+ });
+
+ it('starts from the declared theme', () => {
+ setHostTheme('dark');
+ render();
+
+ expect(screen.getByTestId('mode').textContent).toBe('dark');
+ });
+
+ it('follows the shell when the user toggles the theme while mounted', async () => {
+ setHostTheme('light');
+ render();
+ expect(screen.getByTestId('mode').textContent).toBe('light');
+
+ await act(async () => {
+ setHostTheme('dark');
+ });
+
+ expect(screen.getByTestId('mode').textContent).toBe('dark');
+ });
+
+ it('follows the OS only while the shell has declared nothing', () => {
+ const media = stubMatchMedia(false);
+ render();
+ expect(screen.getByTestId('mode').textContent).toBe('light');
+ expect(media.listenerCount()).toBe(1);
+
+ act(() => {
+ media.set(true);
+ });
+
+ expect(screen.getByTestId('mode').textContent).toBe('dark');
+ });
+
+ it('does not subscribe to the OS when the shell declares a theme', () => {
+ const media = stubMatchMedia(true);
+ setHostTheme('light');
+ render();
+
+ // The shell already resolved 'system' for us, so a second source would
+ // let the OS override an explicit light/dark choice.
+ expect(screen.getByTestId('mode').textContent).toBe('light');
+ expect(media.listenerCount()).toBe(0);
+ });
+});
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/theme/hostTheme.ts b/zeppelin-web-angular/projects/zeppelin-react/src/theme/hostTheme.ts
new file mode 100644
index 00000000000..36368c6e5d0
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/theme/hostTheme.ts
@@ -0,0 +1,78 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { useEffect, useState } from 'react';
+
+export type HostThemeMode = 'light' | 'dark';
+
+/**
+ * The Angular shell's ThemeService resolves 'system' for us and writes the
+ * result to the document root as `data-theme` plus a `dark`/`light` class.
+ * Reading that is what keeps the remote in step with the host without the
+ * host having to thread a prop through every mount point.
+ */
+export const readHostTheme = (): HostThemeMode => {
+ const root = document.documentElement;
+ const declared = root.getAttribute('data-theme');
+ if (declared === 'dark' || declared === 'light') {
+ return declared;
+ }
+ if (root.classList.contains('dark')) {
+ return 'dark';
+ }
+ if (root.classList.contains('light')) {
+ return 'light';
+ }
+
+ // Standalone dev server (port 3001) has no shell, so fall back to the OS
+ // preference the shell would have resolved itself.
+ if (typeof window.matchMedia === 'function') {
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ }
+
+ return 'light';
+};
+
+const hostDeclaresTheme = (): boolean => {
+ const root = document.documentElement;
+ return root.hasAttribute('data-theme') || root.classList.contains('dark') || root.classList.contains('light');
+};
+
+/** Resolved host theme, kept up to date while mounted. */
+export const useHostTheme = (): HostThemeMode => {
+ const [mode, setMode] = useState(readHostTheme);
+
+ useEffect(() => {
+ // The shell can apply its theme after the remote mounts, so re-read once
+ // the subscription is in place rather than trusting the initial render.
+ setMode(readHostTheme());
+ const sync = () => setMode(readHostTheme());
+
+ const observer = new MutationObserver(sync);
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
+
+ // Only follow the OS while the shell has not declared a theme; once it
+ // has, its value already accounts for the 'system' setting.
+ let media: MediaQueryList | undefined;
+ if (!hostDeclaresTheme() && typeof window.matchMedia === 'function') {
+ media = window.matchMedia('(prefers-color-scheme: dark)');
+ media.addEventListener('change', sync);
+ }
+
+ return () => {
+ observer.disconnect();
+ media?.removeEventListener('change', sync);
+ };
+ }, []);
+
+ return mode;
+};
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/theme/index.ts b/zeppelin-web-angular/projects/zeppelin-react/src/theme/index.ts
new file mode 100644
index 00000000000..a9c900c9e28
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/theme/index.ts
@@ -0,0 +1,15 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export type { HostThemeMode } from './hostTheme';
+export { ZeppelinThemeProvider, useHostThemeMode } from './ZeppelinThemeProvider';
+export { applyChartTheme } from './chartTheme';