From 9c3041f8c314ae4cbd0ce2342fbec7a350031cc8 Mon Sep 17 00:00:00 2001 From: kimyenac Date: Fri, 14 Aug 2026 09:33:09 +0900 Subject: [PATCH] [ZEPPELIN-6640] Theme the React remote from the host instead of the shell's CSS The remote wraps its tree in antd's ConfigProvider but never sets an algorithm, so it always builds light styles. Dark mode looks right only because the shell's global ng-zorro-antd rules target the same .ant-* class names, which leaves anything the shell cannot reach in light. The chart.js canvas keeps its '#666' default text, about 3.2:1 against the dark page background, and its grid lines are invisible. Read the theme the shell already publishes on the document root, pick antd's dark or default algorithm from it, and set the two chart.js globals that ticks, legend labels and grid lines resolve from. No Angular change is needed. --- .../projects/zeppelin-react/README.md | 15 ++- .../visualizations/TableVisualization.tsx | 8 +- .../src/pages/PublishedParagraph.tsx | 30 ++--- .../src/theme/ZeppelinThemeProvider.spec.tsx | 93 +++++++++++++ .../src/theme/ZeppelinThemeProvider.tsx | 47 +++++++ .../src/theme/chartTheme.spec.ts | 51 ++++++++ .../zeppelin-react/src/theme/chartTheme.ts | 37 ++++++ .../src/theme/hostTheme.spec.tsx | 122 ++++++++++++++++++ .../zeppelin-react/src/theme/hostTheme.ts | 78 +++++++++++ .../zeppelin-react/src/theme/index.ts | 15 +++ 10 files changed, 474 insertions(+), 22 deletions(-) create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.spec.tsx create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.tsx create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/theme/chartTheme.spec.ts create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/theme/chartTheme.ts create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/theme/hostTheme.spec.tsx create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/theme/hostTheme.ts create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/theme/index.ts diff --git a/zeppelin-web-angular/projects/zeppelin-react/README.md b/zeppelin-web-angular/projects/zeppelin-react/README.md index a32e90643f3..f9d1b1d6317 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/README.md +++ b/zeppelin-web-angular/projects/zeppelin-react/README.md @@ -89,6 +89,7 @@ src/ │ └── PublishedParagraph.tsx # entry component + mount() ├── templates/ │ └── SingleResultRenderer.tsx # routes result types to renderers +├── theme/ # host theme detection, antd + chart.js theming ├── utils/ # tableUtils, textUtils, exportFile └── main.ts # re-exports for Module Federation ``` @@ -109,12 +110,18 @@ export function mount(element: HTMLElement, props: Props): ReactMountHandle; 1. Create a component (e.g. `src/components//ExampleFeature.tsx`). 2. Wrap its render tree in ``. -3. Export a `mount(element, props)` function that: +3. Wrap it in `` as well (see `src/theme/`), otherwise + antd builds its styles from the default light algorithm and the module only + looks right in dark mode while the shell's global `.ant-*` rules happen to + cover the components in use. Pass surface specific tokens through its + `token` prop, and read `useHostThemeMode()` when you draw outside antd, as + a canvas chart does. +4. Export a `mount(element, props)` function that: - Creates a single `Root` via `createRoot(element)`. - Calls `root.render()` on initial mount AND on every `update(newProps)` call. React's reconciler preserves state. - Returns `{ update, unmount }`. `unmount` calls `root.unmount()`. -4. Register in `webpack.config.js` under `exposes`: +5. Register in `webpack.config.js` under `exposes`: ```js exposes: { './PublishedParagraph': './src/pages/PublishedParagraph', @@ -122,14 +129,14 @@ export function mount(element: HTMLElement, props: Props): ReactMountHandle; './ExampleFeature': './src/components//ExampleFeature' } ``` -5. Re-export from `main.ts`: +6. Re-export from `main.ts`: ```ts export { ExampleFeature, mount as mountExampleFeature } from './components//ExampleFeature'; ``` -6. Use from Angular by adding the directive to your template: +7. Use from Angular by adding the directive to your template: ```html
{ const [currentMode, setCurrentMode] = useState(config?.graph.mode || 'table'); const chartRef = useRef(null); + const themeMode = useHostThemeMode(); const tableData = useMemo(() => parseTableData(result.data), [result.data]); @@ -86,6 +88,10 @@ export const TableVisualization = ({ result, config }: TableVisualizationProps) const ChartConstructor = module.Chart || module.default; + // Ticks, legend labels and grid lines all resolve from these two + // globals, and a canvas is out of reach of the shell's stylesheets. + applyChartTheme(ChartConstructor, themeMode); + const canvas = document.createElement('canvas'); canvas.style.width = '100%'; canvas.style.height = '100%'; @@ -222,7 +228,7 @@ export const TableVisualization = ({ result, config }: TableVisualizationProps) container.innerHTML = ''; } }; - }, [currentMode, tableData]); + }, [currentMode, tableData, themeMode]); return (
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';