diff --git a/client/src/style.css b/client/src/style.css index 896e176f7e0..c5e51201fb4 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -2465,11 +2465,20 @@ html[data-input-modality='keyboard'] outline-offset: 2px !important; } +/* Black and white are the default theme's focus outline. A theme that names its + * own `ring-primary` (`data-theme-ring` on the root) draws the outline in it, + * at the same specificity and layer as the rule it recolours, so every utility + * that beat the outline before still does. A theme that leaves the ring to the + * default keeps black and white, and the contrast modes keep their own rule. */ @layer base { :focus-visible { outline: 2px solid #000; outline-offset: 2px; } + + :where(html[data-theme-ring]:not(.high-contrast)) :focus-visible { + outline-color: rgb(var(--ring-primary)); + } } .dark :focus-visible { @@ -2477,6 +2486,10 @@ html[data-input-modality='keyboard'] outline-offset: 2px; } +.dark:where([data-theme-ring]:not(.high-contrast)) :focus-visible { + outline-color: rgb(var(--ring-primary)); +} + .popover-ui { display: flex; max-height: min(var(--popover-available-height, 1700px), 1700px); diff --git a/e2e/specs/mock/scenarios/clickhouse-theme-shape.spec.ts b/e2e/specs/mock/scenarios/clickhouse-theme-shape.spec.ts index c2718b81482..362a63e31f9 100644 --- a/e2e/specs/mock/scenarios/clickhouse-theme-shape.spec.ts +++ b/e2e/specs/mock/scenarios/clickhouse-theme-shape.spec.ts @@ -130,6 +130,25 @@ test.describe('ClickHouse theme shape', () => { expect(await probeStyle(page, 'ring-2 ring-ring-primary', 'box-shadow')).toContain(ring); }); + test('code text, the second motion step and the hairline shadow follow Click UI @scenario:clickhouse-code-text-and-motion-follow-click-ui', async ({ + page, + }) => { + await storeClickHouse(page); + + await openMentionMenu(page); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'clickhouse'); + const mode = await resolvedMode(page); + + /** `codeblock.lightMode.color.text` and `codeblock.darkMode.color.text`. */ + const code = mode === 'light' ? 'rgb(40, 40, 40)' : 'rgb(255, 255, 255)'; + expect(await probeStyle(page, 'hljs', 'color', 'code')).toBe(code); + /** `transition.duration.smooth`, and `shadow.5` for the lightest shadow step. */ + expect(await probeStyle(page, 'duration-theme-normal', 'transition-duration')).toBe('0.15s'); + expect(await probeStyle(page, 'shadow-2xs', 'box-shadow')).toContain( + 'rgba(0, 0, 0, 0.03) 0px 2px 2px 0px', + ); + }); + test('monospace text under the ClickHouse theme leads with Inconsolata @scenario:clickhouse-mono-font-leads-with-inconsolata', async ({ page, }) => { diff --git a/e2e/specs/mock/scenarios/focus-outline.spec.ts b/e2e/specs/mock/scenarios/focus-outline.spec.ts new file mode 100644 index 00000000000..31c448c7be4 --- /dev/null +++ b/e2e/specs/mock/scenarios/focus-outline.spec.ts @@ -0,0 +1,191 @@ +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { clickHouseTheme } from '../../../../packages/client/src/theme/themes/clickhouse'; +import { NEW_CHAT_PATH } from '../helpers'; + +/** + * The app-wide keyboard focus outline. The default light and dark themes draw it in black and + * white, a theme definition that names its own `ring-primary` draws it in that ring, one that + * leaves the ring to the default keeps black and white, and the contrast modes keep their heavier + * text-colored outline. The probe is a bare button reached with Tab, so nothing but the + * global rule styles its outline. + */ + +type Appearance = 'light' | 'dark' | 'high-contrast-light' | 'high-contrast-dark'; +type Outline = { color: string; style: string; width: string; offset: string }; + +const PROBE = 'focus-outline-probe'; + +const CUSTOM_RING_THEME = { + version: 1, + name: 'e2e-focus-ring', + modes: { + light: { colors: { 'rgb-ring-primary': '10 20 30' } }, + dark: { colors: { 'rgb-ring-primary': '200 210 220' } }, + }, +} as const; + +/** Leaves `rgb-ring-primary` to the default, which resolves to a gray too dim for a dark surface. */ +const RINGLESS_THEME = { + version: 1, + name: 'e2e-ringless', + modes: { + light: { colors: { 'rgb-accent-primary': '10 20 30' } }, + dark: { colors: { 'rgb-accent-primary': '200 210 220' } }, + }, +} as const; + +async function installAppearance(page: Page, appearance: Appearance, definition?: unknown) { + await page.addInitScript( + ([mode, stored]) => { + localStorage.setItem('color-theme', mode as string); + localStorage.removeItem('theme-colors'); + localStorage.removeItem('theme-name'); + if (stored) { + localStorage.setItem('theme-definition', JSON.stringify(stored)); + localStorage.setItem('theme-source', 'definition'); + } else { + localStorage.removeItem('theme-definition'); + localStorage.removeItem('theme-source'); + } + }, + [appearance, definition ?? null] as [string, unknown], + ); +} + +/** + * Tabs onto a bare button and reads the outline it draws. Tab moves on from the focused element, + * so a sentinel that takes script focus but sits outside the tab order anchors the move. + */ +async function keyboardFocusOutline(page: Page): Promise { + await page.evaluate((id) => { + const sentinel = document.createElement('span'); + sentinel.tabIndex = -1; + const probe = document.createElement('button'); + probe.id = id; + probe.textContent = 'Focus probe'; + document.body.prepend(sentinel, probe); + sentinel.focus(); + }, PROBE); + await page.keyboard.press('Tab'); + + const probe = page.locator(`#${PROBE}`); + await expect(probe).toBeFocused(); + expect(await probe.evaluate((node) => node.matches(':focus-visible'))).toBe(true); + + return probe.evaluate((node) => { + const style = getComputedStyle(node); + return { + color: style.outlineColor, + style: style.outlineStyle, + width: style.outlineWidth, + offset: style.outlineOffset, + }; + }); +} + +async function openChat(page: Page, appearance: Appearance, definition?: { name: string }) { + await installAppearance(page, appearance, definition); + await page.goto(NEW_CHAT_PATH, { timeout: 15000 }); + await expect(page.getByRole('textbox', { name: 'Message input' })).toBeVisible({ + timeout: 30000, + }); + const root = page.locator('html'); + if (appearance.startsWith('high-contrast')) { + await expect(root).toHaveClass(/\bhigh-contrast\b/); + } else if (definition) { + await expect(root).toHaveAttribute('data-theme', definition.name); + } else { + await expect(root).not.toHaveAttribute('data-theme'); + } + await expect(root).toHaveClass(appearance.endsWith('dark') ? /\bdark\b/ : /\blight\b/); +} + +const outline = (color: string, width = '2px'): Outline => ({ + color, + style: 'solid', + width, + offset: '2px', +}); + +/** Each tag is written out whole: the runner finds a scenario by its literal tag. */ +const CASES: Array<{ + title: string; + appearance: Appearance; + definition?: { name: string }; + expected: Outline; +}> = [ + { + title: + 'the default light theme keeps its black focus outline @scenario:focus-outline-default-light-unchanged', + appearance: 'light', + expected: outline('rgb(0, 0, 0)'), + }, + { + title: + 'the default dark theme keeps its white focus outline @scenario:focus-outline-default-dark-unchanged', + appearance: 'dark', + expected: outline('rgb(255, 255, 255)'), + }, + { + title: + 'the ClickHouse light theme draws focus in its outline blue @scenario:focus-outline-follows-clickhouse-ring-light', + appearance: 'light', + definition: clickHouseTheme, + expected: outline('rgb(67, 126, 239)'), + }, + { + title: + 'the ClickHouse dark theme draws focus in its brand yellow @scenario:focus-outline-follows-clickhouse-ring-dark', + appearance: 'dark', + definition: clickHouseTheme, + expected: outline('rgb(250, 255, 105)'), + }, + { + title: + 'a dark theme that leaves the ring to the default keeps the white focus outline @scenario:focus-outline-ringless-theme-keeps-default', + appearance: 'dark', + definition: RINGLESS_THEME, + expected: outline('rgb(255, 255, 255)'), + }, + { + title: + 'high contrast light keeps its heavy text-colored focus outline @scenario:focus-outline-high-contrast-light-unchanged', + appearance: 'high-contrast-light', + definition: clickHouseTheme, + expected: outline('rgb(0, 0, 0)', '3px'), + }, + { + title: + 'high contrast dark keeps its heavy text-colored focus outline @scenario:focus-outline-high-contrast-dark-unchanged', + appearance: 'high-contrast-dark', + definition: clickHouseTheme, + expected: outline('rgb(255, 255, 255)', '3px'), + }, +]; + +test.describe('keyboard focus outline', () => { + for (const { title, appearance, definition, expected } of CASES) { + test(title, async ({ page }) => { + await openChat(page, appearance, definition); + + expect(await keyboardFocusOutline(page)).toEqual(expected); + }); + } + + test('a custom theme draws focus in the ring it defines, in both modes @scenario:focus-outline-follows-custom-theme-ring', async ({ + page, + }) => { + const rings: Record<'light' | 'dark', string> = { + light: 'rgb(10, 20, 30)', + dark: 'rgb(200, 210, 220)', + }; + /** One page per mode: a page's init scripts accumulate, and their order is not guaranteed. */ + for (const mode of ['light', 'dark'] as const) { + const modePage = mode === 'light' ? page : await page.context().newPage(); + await openChat(modePage, mode, CUSTOM_RING_THEME); + + expect(await keyboardFocusOutline(modePage)).toEqual(outline(rings[mode])); + } + }); +}); diff --git a/packages/client/src/theme/context/ThemeProvider.spec.tsx b/packages/client/src/theme/context/ThemeProvider.spec.tsx index b7c1e43bb80..288ac431090 100644 --- a/packages/client/src/theme/context/ThemeProvider.spec.tsx +++ b/packages/client/src/theme/context/ThemeProvider.spec.tsx @@ -71,6 +71,7 @@ beforeEach(() => { document.documentElement.removeAttribute('class'); document.documentElement.removeAttribute('style'); document.documentElement.removeAttribute('data-theme'); + document.documentElement.removeAttribute('data-theme-ring'); window.matchMedia = jest.fn(() => matchMedia(false)); }); @@ -1137,4 +1138,70 @@ describe('ThemeProvider', () => { expect(screen.getByTestId('resolved-mode')).toHaveTextContent('dark'); expect(matchMediaSpy).toHaveBeenCalled(); }); + describe('the ring mark the global focus outline follows', () => { + it('marks only the modes whose definition names its ring, and clears it on unmount', async () => { + const { unmount } = render( + + + , + ); + + await waitFor(() => { + expect(document.documentElement).toHaveAttribute('data-theme-ring'); + }); + + act(() => screen.getByRole('button', { name: 'Dark' }).click()); + + await waitFor(() => { + expect(document.documentElement).toHaveClass('dark'); + }); + expect(document.documentElement.dataset.theme).toBe('ringed'); + expect(document.documentElement).not.toHaveAttribute('data-theme-ring'); + + unmount(); + + expect(document.documentElement).not.toHaveAttribute('data-theme-ring'); + }); + + it('marks legacy RGB props only when they carry a ring', async () => { + const { rerender } = render( + + + , + ); + + await waitFor(() => { + expect(document.documentElement).toHaveAttribute('data-theme-ring'); + }); + + rerender( + + + , + ); + + await waitFor(() => { + expect(document.documentElement).not.toHaveAttribute('data-theme-ring'); + }); + expect(document.documentElement.dataset.theme).toBe('legacy'); + }); + }); }); diff --git a/packages/client/src/theme/context/ThemeProvider.tsx b/packages/client/src/theme/context/ThemeProvider.tsx index 82467fbe217..2fca707987a 100644 --- a/packages/client/src/theme/context/ThemeProvider.tsx +++ b/packages/client/src/theme/context/ThemeProvider.tsx @@ -16,7 +16,11 @@ import { resolveTheme, validateThemeDefinition, } from '../registry'; -import applyTheme, { applyResolvedTheme, themeOwnedProperties } from '../utils/applyTheme'; +import applyTheme, { + applyResolvedTheme, + themeOwnedProperties, + THEME_RING_ATTRIBUTE, +} from '../utils/applyTheme'; import { defaultTheme } from '../themes/default'; import { darkTheme } from '../themes/dark'; import '../highContrast.css'; @@ -48,6 +52,7 @@ type ThemeDOMSnapshot = { properties: Map; colorScheme: { value: string; priority: string }; dataTheme: string | null; + themeRing: boolean; }; type ThemeClassSnapshot = { @@ -282,6 +287,7 @@ const captureThemeDOM = (root: HTMLElement): ThemeDOMSnapshot => ({ priority: root.style.getPropertyPriority('color-scheme'), }, dataTheme: root.getAttribute('data-theme'), + themeRing: root.hasAttribute(THEME_RING_ATTRIBUTE), }); const restoreThemeDOM = (snapshot: ThemeDOMSnapshot, root: HTMLElement): void => { @@ -308,6 +314,7 @@ const restoreThemeDOM = (snapshot: ThemeDOMSnapshot, root: HTMLElement): void => } else { root.setAttribute('data-theme', snapshot.dataTheme); } + root.toggleAttribute(THEME_RING_ATTRIBUTE, snapshot.themeRing); }; export function ThemeProvider({ @@ -641,11 +648,16 @@ export function ThemeProvider({ if (!highContrast && legacyThemeRGB) { applyTheme(legacyThemeRGB, root, mode === 'dark' ? darkTheme : defaultTheme); root.dataset.theme = definition.name; + root.toggleAttribute(THEME_RING_ATTRIBUTE, legacyThemeRGB['rgb-ring-primary'] != null); return; } try { applyResolvedTheme(resolveTheme(definition, mode), root); + root.toggleAttribute( + THEME_RING_ATTRIBUTE, + definition.modes[mode]?.colors?.['rgb-ring-primary'] != null, + ); } catch (error) { restoreAppliedTheme(root); console.error('Unable to apply theme definition', error); diff --git a/packages/client/src/theme/themes/clickhouse.ts b/packages/client/src/theme/themes/clickhouse.ts index 28a21a9515a..e9819fc62e9 100644 --- a/packages/client/src/theme/themes/clickhouse.ts +++ b/packages/client/src/theme/themes/clickhouse.ts @@ -3,12 +3,14 @@ import type { IThemeAppearance, ThemeDefinition, IThemeRGB } from '../types'; /** * ClickHouse reference theme, built from Click UI's design tokens * (github.com/ClickHouse/click-ui, `src/theme/tokens/variables.light.ts` and - * `variables.dark.ts` at 7dad15bb978d03ba63575e1d4abc02b8927ee1cd). + * `variables.dark.ts` at tag v0.12.0, e2b3d213798f4223122581a75ebc8dd91e47fd9f). The tokens it + * cites are pinned in `clickui.json`, and `clickui.spec.ts` fails on drift in either one. * * This is the deliberately different theme that proves the engine repaints the * app from data alone: no component reads it by name, and LibreChat's defaults * are untouched. Each comment names the Click UI token a value comes from; paths - * without a prefix are under `global.color`, `palette.*` is Click UI's ramp. + * without a prefix are under `global.color` (or `click.global.color` for `text.warning` and + * `text.danger`), `palette.*` is Click UI's ramp, and component paths are under `click`. * * Both maps are complete, so nothing falls back to the LibreChat palette. Where * a verbatim Click UI value missed a contrast floor in `clickhouse.spec.ts` or @@ -76,8 +78,8 @@ export const clickHouseLightTheme: IThemeRGB = { 'rgb-surface-destructive': '193 0 0', // #c10000 (palette.danger.600) 'rgb-surface-destructive-hover': '145 0 0', // #910000 (palette.danger.700) 'rgb-surface-chat': '255 255 255', // #ffffff (background.default) - 'rgb-surface-code': '246 247 250', // #f6f7fa (background.muted) - 'rgb-surface-code-body': '246 247 250', // #f6f7fa (background.muted) + 'rgb-surface-code': '246 247 250', // #f6f7fa (codeblock.lightMode.color.background.default) + 'rgb-surface-code-body': '246 247 250', // #f6f7fa (codeblock.lightMode.color.background.default) 'rgb-surface-inverted': '21 21 21', // #151515 (palette.neutral.900) 'rgb-surface-inverted-hover': '50 50 50', // #323232 (palette.neutral.712) 'rgb-text-inverted': '255 255 255', // #ffffff (palette.neutral.0) @@ -121,7 +123,7 @@ export const clickHouseLightTheme: IThemeRGB = { 'rgb-brand-purple': '136 0 204', // #8800cc (palette.violet.600) // Code syntax - 'rgb-syntax-text': '22 21 23', // #161517 (text.default) + 'rgb-syntax-text': '40 40 40', // #282828 (codeblock.lightMode.color.text.default) 'rgb-syntax-comment': '105 110 121', // #696e79 (text.muted) 'rgb-syntax-meta': '83 87 95', // #53575f (palette.slate.700) 'rgb-syntax-builtin': '138 105 0', // #8a6900 (palette.sunrise.700) @@ -190,14 +192,17 @@ export const clickHouseDarkTheme: IThemeRGB = { 'rgb-surface-tertiary': '40 40 40', // #282828 (background.muted) 'rgb-surface-tertiary-alt': '50 50 50', // #323232 (palette.neutral.712) 'rgb-surface-dialog': '31 31 28', // #1f1f1c (background.default) - 'rgb-surface-overlay': '0 0 0', // #000000 (palette.utility black) + /** Not a Click UI value. Its dark scrim, dialog.color.opaqueBackground lch(40.731 0 none), is a + * #606060 gray that lifts the page instead of dimming it, and leaves the dialog surface under the + * 3:1 boundary the scrim gives it; black keeps the dark theme's scrim. */ + 'rgb-surface-overlay': '0 0 0', // #000000 'rgb-surface-submit': '250 255 105', // #faff69 (accent.default) 'rgb-surface-submit-hover': '253 255 163', // #fdffa3 (palette.brand.200) 'rgb-surface-destructive': '255 117 117', // #ff7575 (palette.danger.300) 'rgb-surface-destructive-hover': '255 152 152', // #ff9898 (palette.danger.200) 'rgb-surface-chat': '31 31 28', // #1f1f1c (background.default) - 'rgb-surface-code': '40 40 40', // #282828 (codeblock.darkMode.background.default) - 'rgb-surface-code-body': '40 40 40', // #282828 (codeblock.darkMode.background.default) + 'rgb-surface-code': '40 40 40', // #282828 (codeblock.darkMode.color.background.default) + 'rgb-surface-code-body': '40 40 40', // #282828 (codeblock.darkMode.color.background.default) 'rgb-surface-inverted': '250 255 105', // #faff69 (button.basic.color.primary.background.default) 'rgb-surface-inverted-hover': '252 255 150', // #fcff96 (button.basic.color.primary.background.hover) 'rgb-text-inverted': '31 31 28', // #1f1f1c (button.basic.color.primary.text.default) @@ -241,7 +246,7 @@ export const clickHouseDarkTheme: IThemeRGB = { 'rgb-brand-purple': '204 102 255', // #cc66ff (palette.violet.300) // Code syntax - 'rgb-syntax-text': '255 255 255', // #ffffff (codeblock.darkMode.text.default) + 'rgb-syntax-text': '255 255 255', // #ffffff (codeblock.darkMode.color.text.default) 'rgb-syntax-comment': '179 182 189', // #b3b6bd (text.muted) 'rgb-syntax-meta': '154 158 167', // #9a9ea7 (palette.slate.400) 'rgb-syntax-builtin': '255 195 0', // #ffc300 (chart.default.sunrise) @@ -300,10 +305,12 @@ const clickHouseShape = { '"Inter", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif', monoFontFamily: '"Inconsolata", ui-monospace, SFMono-Regular, Menlo, "Cascadia Mono", "Liberation Mono", Consolas, monospace', + shadow2xs: '0 2px 2px 0 rgb(0 0 0 / 0.03)', // shadow.5 shadowXs: '0 2px 2px 0 rgb(0 0 0 / 0.03)', // shadow.5 shadowSm: '0 2px 2px 0 rgb(0 0 0 / 0.03)', // shadow.5 controlHeight: '2rem', // genericMenu.panel.size.height - motionFast: '100ms', // transition.default + motionFast: '100ms', // transition.duration.medium, the step transition.default runs at + motionNormal: '150ms', // transition.duration.smooth }; const elevation = (alpha: number): string => diff --git a/packages/client/src/theme/themes/clickui.json b/packages/client/src/theme/themes/clickui.json new file mode 100644 index 00000000000..77439130bda --- /dev/null +++ b/packages/client/src/theme/themes/clickui.json @@ -0,0 +1,146 @@ +{ + "source": "https://github.com/ClickHouse/click-ui", + "tag": "v0.12.0", + "commit": "e2b3d213798f4223122581a75ebc8dd91e47fd9f", + "files": { + "light": "src/theme/tokens/variables.light.ts", + "dark": "src/theme/tokens/variables.dark.ts" + }, + "light": { + "border.radii.1": "0.25rem", + "border.radii.2": "0.5rem", + "border.radii.3": "0.75rem", + "border.radii.full": "9999px", + "click.codeblock.lightMode.color.background.default": "#f6f7fa", + "click.codeblock.lightMode.color.text.default": "#282828", + "click.dialog.color.opaqueBackground.default": "lch(6.7738 0 none / 0.75)", + "click.genericMenu.panel.size.height": "2rem", + "click.global.color.text.danger": "#c10000", + "click.global.color.text.warning": "#a33c00", + "global.color.accent.default": "#151515", + "global.color.background.default": "#ffffff", + "global.color.background.muted": "#f6f7fa", + "global.color.background.split": "#f6f7fa", + "global.color.chart.default.blue": "#437eef", + "global.color.chart.default.fuchsia": "#fb32c9", + "global.color.chart.default.teal": "#089b83", + "global.color.chart.default.violet": "#bb33ff", + "global.color.feedback.danger.background": "#ffdddd", + "global.color.feedback.danger.foreground": "#c10000", + "global.color.feedback.info.background": "#e7effd", + "global.color.feedback.neutral.background": "#f6f7fa", + "global.color.feedback.neutral.foreground": "#53575f", + "global.color.feedback.neutral.stroke": "#e6e7e9", + "global.color.feedback.success.background": "#e5ffe8", + "global.color.feedback.success.foreground": "#008a0b", + "global.color.feedback.warning.background": "#ffe2d1", + "global.color.feedback.warning.foreground": "#a33c00", + "global.color.outline.default": "#437eef", + "global.color.stroke.default": "#e6e7e9", + "global.color.stroke.intense": "#b3b6bd", + "global.color.text.default": "#161517", + "global.color.text.link.hover": "#104ec6", + "global.color.text.muted": "#696e79", + "palette.babyblue.600": "#008599", + "palette.danger.200": "#ff9898", + "palette.danger.600": "#c10000", + "palette.danger.700": "#910000", + "palette.fuchsia.700": "#990073", + "palette.info.200": "#a1bef7", + "palette.info.500": "#1d64ec", + "palette.info.600": "#104ec6", + "palette.neutral.0": "#ffffff", + "palette.neutral.712": "#323232", + "palette.neutral.900": "#151515", + "palette.slate.100": "#e6e7e9", + "palette.slate.200": "#cccfd3", + "palette.slate.500": "#808691", + "palette.slate.700": "#53575f", + "palette.slate.900": "#161517", + "palette.success.200": "#99ffa1", + "palette.success.700": "#008a0b", + "palette.success.800": "#006108", + "palette.sunrise.600": "#b28800", + "palette.sunrise.700": "#8a6900", + "palette.violet.600": "#8800cc", + "palette.warning.200": "#ffb88f", + "palette.warning.500": "#f55a00", + "palette.warning.700": "#a33c00", + "shadow.1": "0 4px 6px -1px lch(6.7738 0 none / 0.15), 0 2px 4px -1px lch(6.7738 0 none / 0.15)", + "shadow.5": "0 2px 2px 0 rgba(0, 0, 0, 0.03)", + "transition.duration.medium": "100ms", + "transition.duration.smooth": "150ms", + "typography.font.families.mono": "\"Inconsolata\", Consolas, \"SFMono Regular\", monospace", + "typography.font.families.regular": "\"Inter\", \"SF Pro Display\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Oxygen, Ubuntu, Cantarell, \"Open Sans\", \"Helvetica Neue\", sans-serif;" + }, + "dark": { + "border.radii.1": "0.25rem", + "border.radii.2": "0.5rem", + "border.radii.3": "0.75rem", + "border.radii.full": "9999px", + "click.button.basic.color.primary.background.default": "#faff69", + "click.button.basic.color.primary.background.hover": "rgb(98.627% 100% 58.824%)", + "click.button.basic.color.primary.text.default": "#1f1f1c", + "click.codeblock.darkMode.color.background.default": "#282828", + "click.codeblock.darkMode.color.text.default": "#ffffff", + "click.genericMenu.panel.size.height": "2rem", + "click.global.color.text.danger": "#ffbaba", + "click.global.color.text.warning": "#ffb88f", + "global.color.accent.default": "#faff69", + "global.color.background.default": "#1f1f1c", + "global.color.background.muted": "#282828", + "global.color.background.split": "#282828", + "global.color.chart.default.babyblue": "#00cbeb", + "global.color.chart.default.blue": "#437eef", + "global.color.chart.default.fuchsia": "#fb64d6", + "global.color.chart.default.green": "#33ff44", + "global.color.chart.default.orange": "#ff7729", + "global.color.chart.default.sunrise": "#ffc300", + "global.color.chart.default.teal": "#6df8e1", + "global.color.chart.default.violet": "#bb33ff", + "global.color.feedback.danger.background": "#610000", + "global.color.feedback.danger.foreground": "#ffbaba", + "global.color.feedback.info.background": "#0d3e9b", + "global.color.feedback.info.foreground": "#d0dffb", + "global.color.feedback.neutral.background": "#414141", + "global.color.feedback.neutral.foreground": "#f9f9f9", + "global.color.feedback.neutral.stroke": "#323232", + "global.color.feedback.success.background": "#004206", + "global.color.feedback.success.foreground": "#ccffd0", + "global.color.feedback.warning.background": "#7a2d00", + "global.color.feedback.warning.foreground": "#ffb88f", + "global.color.iconButton.badge.foreground": "#1d1d1d", + "global.color.outline.default": "#faff69", + "global.color.stroke.default": "#323232", + "global.color.stroke.intense": "#414141", + "global.color.text.default": "#ffffff", + "global.color.text.link.default": "#faff69", + "global.color.text.link.hover": "#feffc2", + "global.color.text.muted": "#b3b6bd", + "palette.brand.200": "#fdffa3", + "palette.danger.200": "#ff9898", + "palette.danger.300": "#ff7575", + "palette.danger.700": "#910000", + "palette.info.300": "#6d9bf3", + "palette.info.600": "#104ec6", + "palette.neutral.0": "#ffffff", + "palette.neutral.500": "#808080", + "palette.neutral.700": "#414141", + "palette.neutral.712": "#323232", + "palette.slate.100": "#e6e7e9", + "palette.slate.400": "#9a9ea7", + "palette.slate.900": "#161517", + "palette.success.300": "#66ff73", + "palette.success.500": "#00e513", + "palette.success.800": "#006108", + "palette.violet.300": "#cc66ff", + "palette.warning.300": "#ff9457", + "palette.warning.700": "#a33c00", + "shadow.1": "0 4px 6px -1px rgb(8.2353% 8.2353% 8.2353% / 0.6), 0 2px 4px -1px rgb(8.2353% 8.2353% 8.2353% / 0.6)", + "shadow.5": "0 2px 2px 0 rgba(0, 0, 0, 0.03)", + "transition.duration.medium": "100ms", + "transition.duration.smooth": "150ms", + "typography.font.families.mono": "\"Inconsolata\", Consolas, \"SFMono Regular\", monospace", + "typography.font.families.regular": "\"Inter\", \"SF Pro Display\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Oxygen, Ubuntu, Cantarell, \"Open Sans\", \"Helvetica Neue\", sans-serif;" + } +} diff --git a/packages/client/src/theme/themes/clickui.spec.ts b/packages/client/src/theme/themes/clickui.spec.ts new file mode 100644 index 00000000000..47bed8cfc8a --- /dev/null +++ b/packages/client/src/theme/themes/clickui.spec.ts @@ -0,0 +1,389 @@ +import type { IThemeAppearance, IThemeRGB, ThemeMode } from '../types'; +import { clickHouseTheme } from './clickhouse'; +import snapshot from './clickui.json'; + +/** + * Drift guard for the ClickHouse theme. `clickui.json` is a snapshot of the Click UI tokens the + * theme cites, copied verbatim from the tag it records; the maps below name the token behind each + * theme value. Editing a theme value, or refreshing the snapshot from a newer Click UI tag, fails + * here with the theme key and the token it no longer matches. + * + * To refresh: check out the new tag, read each token below out of `files` for its mode, write the + * verbatim values, tag and commit into `clickui.json`, then reconcile what this spec reports. + */ + +type Rgba = [number, number, number, number]; +type Snapshot = Record>; + +const tokens: Snapshot = { light: snapshot.light, dark: snapshot.dark }; +const modes: ThemeMode[] = ['light', 'dark']; + +const colorSources: Record>> = { + light: { + 'rgb-text-primary': 'global.color.text.default', + 'rgb-text-secondary': 'palette.slate.700', + 'rgb-text-secondary-alt': 'palette.slate.700', + 'rgb-text-tertiary': 'palette.slate.700', + 'rgb-text-muted': 'global.color.text.muted', + 'rgb-text-warning': 'click.global.color.text.warning', + 'rgb-text-destructive': 'click.global.color.text.danger', + 'rgb-shimmer-base': 'global.color.text.default', + 'rgb-shimmer-dip': 'palette.slate.500', + 'rgb-link': 'palette.info.500', + 'rgb-link-hover': 'global.color.text.link.hover', + 'rgb-link-visited': 'palette.violet.600', + 'rgb-accent-primary': 'global.color.accent.default', + 'rgb-accent-primary-hover': 'palette.neutral.712', + 'rgb-ring-primary': 'global.color.outline.default', + 'rgb-header-primary': 'global.color.background.default', + 'rgb-header-hover': 'global.color.background.muted', + 'rgb-header-button-hover': 'global.color.background.muted', + 'rgb-surface-active': 'palette.slate.100', + 'rgb-surface-active-alt': 'palette.slate.200', + 'rgb-surface-hover': 'palette.slate.100', + 'rgb-surface-hover-alt': 'palette.slate.200', + 'rgb-surface-composer-hover': 'palette.slate.100', + 'rgb-surface-primary': 'global.color.background.default', + 'rgb-chart-widget-surface': 'global.color.background.default', + 'rgb-chart-widget-stroke': 'global.color.stroke.default', + 'rgb-surface-primary-alt': 'global.color.background.split', + 'rgb-surface-primary-contrast': 'palette.slate.100', + 'rgb-surface-secondary': 'global.color.background.muted', + 'rgb-surface-secondary-alt': 'palette.slate.100', + 'rgb-surface-tertiary': 'global.color.background.muted', + 'rgb-surface-tertiary-alt': 'global.color.background.default', + 'rgb-surface-dialog': 'global.color.background.default', + 'rgb-surface-overlay': 'click.dialog.color.opaqueBackground.default', + 'rgb-surface-submit': 'global.color.accent.default', + 'rgb-surface-submit-hover': 'palette.neutral.712', + 'rgb-surface-destructive': 'palette.danger.600', + 'rgb-surface-destructive-hover': 'palette.danger.700', + 'rgb-surface-chat': 'global.color.background.default', + 'rgb-surface-code': 'click.codeblock.lightMode.color.background.default', + 'rgb-surface-code-body': 'click.codeblock.lightMode.color.background.default', + 'rgb-surface-inverted': 'palette.neutral.900', + 'rgb-surface-inverted-hover': 'palette.neutral.712', + 'rgb-text-inverted': 'palette.neutral.0', + 'rgb-surface-fixed': 'palette.neutral.0', + 'rgb-surface-fixed-hover': 'palette.slate.100', + 'rgb-text-fixed': 'palette.slate.900', + 'rgb-border-light': 'global.color.stroke.default', + 'rgb-border-medium': 'global.color.stroke.default', + 'rgb-border-medium-alt': 'global.color.stroke.default', + 'rgb-border-heavy': 'global.color.stroke.intense', + 'rgb-border-xheavy': 'palette.slate.500', + 'rgb-border-destructive': 'palette.danger.600', + 'rgb-border-control': 'palette.slate.500', + 'rgb-status-success': 'palette.success.800', + 'rgb-status-success-subtle': 'global.color.feedback.success.background', + 'rgb-status-success-border': 'palette.success.200', + 'rgb-status-success-strong': 'global.color.feedback.success.foreground', + 'rgb-status-info': 'palette.info.600', + 'rgb-status-info-subtle': 'global.color.feedback.info.background', + 'rgb-status-info-border': 'palette.info.200', + 'rgb-status-info-strong': 'palette.info.600', + 'rgb-status-warning': 'global.color.feedback.warning.foreground', + 'rgb-status-warning-subtle': 'global.color.feedback.warning.background', + 'rgb-status-warning-border': 'palette.warning.200', + 'rgb-status-warning-strong': 'palette.warning.700', + 'rgb-status-error': 'global.color.feedback.danger.foreground', + 'rgb-status-error-subtle': 'global.color.feedback.danger.background', + 'rgb-status-error-border': 'palette.danger.200', + 'rgb-status-error-strong': 'palette.danger.600', + 'rgb-status-neutral': 'global.color.feedback.neutral.foreground', + 'rgb-status-neutral-subtle': 'global.color.feedback.neutral.background', + 'rgb-status-neutral-border': 'global.color.feedback.neutral.stroke', + 'rgb-status-verified': 'palette.info.600', + 'rgb-text-on-status': 'palette.neutral.0', + 'rgb-brand-purple': 'palette.violet.600', + 'rgb-syntax-text': 'click.codeblock.lightMode.color.text.default', + 'rgb-syntax-comment': 'global.color.text.muted', + 'rgb-syntax-meta': 'palette.slate.700', + 'rgb-syntax-builtin': 'palette.sunrise.700', + 'rgb-syntax-keyword': 'palette.info.600', + 'rgb-syntax-string': 'palette.success.800', + 'rgb-syntax-attr': 'palette.fuchsia.700', + 'rgb-syntax-title': 'palette.warning.700', + 'rgb-series-1': 'global.color.chart.default.blue', + 'rgb-series-2': 'palette.warning.500', + 'rgb-series-3': 'palette.success.700', + 'rgb-series-4': 'global.color.chart.default.fuchsia', + 'rgb-series-5': 'palette.sunrise.600', + 'rgb-series-6': 'global.color.chart.default.violet', + 'rgb-series-7': 'palette.babyblue.600', + 'rgb-series-8': 'global.color.chart.default.teal', + 'rgb-switch-unchecked': 'palette.slate.500', + 'rgb-presentation': 'global.color.background.default', + }, + dark: { + 'rgb-text-primary': 'global.color.text.default', + 'rgb-text-secondary': 'global.color.text.muted', + 'rgb-text-secondary-alt': 'global.color.text.muted', + 'rgb-text-tertiary': 'global.color.text.muted', + 'rgb-text-muted': 'global.color.text.muted', + 'rgb-text-warning': 'click.global.color.text.warning', + 'rgb-text-destructive': 'click.global.color.text.danger', + 'rgb-shimmer-base': 'global.color.text.default', + 'rgb-shimmer-dip': 'global.color.text.muted', + 'rgb-link': 'global.color.text.link.default', + 'rgb-link-hover': 'global.color.text.link.hover', + 'rgb-link-visited': 'palette.violet.300', + 'rgb-accent-primary': 'global.color.accent.default', + 'rgb-accent-primary-hover': 'palette.brand.200', + 'rgb-ring-primary': 'global.color.outline.default', + 'rgb-header-primary': 'global.color.background.default', + 'rgb-header-hover': 'global.color.background.muted', + 'rgb-header-button-hover': 'global.color.background.muted', + 'rgb-surface-active': 'palette.neutral.700', + 'rgb-surface-active-alt': 'palette.neutral.712', + 'rgb-surface-hover': 'palette.neutral.712', + 'rgb-surface-hover-alt': 'palette.neutral.700', + 'rgb-surface-composer-hover': 'palette.neutral.712', + 'rgb-surface-primary': 'global.color.background.default', + 'rgb-chart-widget-surface': 'global.color.background.muted', + 'rgb-chart-widget-stroke': 'global.color.stroke.default', + 'rgb-surface-primary-alt': 'global.color.background.split', + 'rgb-surface-primary-contrast': 'palette.neutral.712', + 'rgb-surface-secondary': 'global.color.background.muted', + 'rgb-surface-secondary-alt': 'palette.neutral.712', + 'rgb-surface-tertiary': 'global.color.background.muted', + 'rgb-surface-tertiary-alt': 'palette.neutral.712', + 'rgb-surface-dialog': 'global.color.background.default', + 'rgb-surface-submit': 'global.color.accent.default', + 'rgb-surface-submit-hover': 'palette.brand.200', + 'rgb-surface-destructive': 'palette.danger.300', + 'rgb-surface-destructive-hover': 'palette.danger.200', + 'rgb-surface-chat': 'global.color.background.default', + 'rgb-surface-code': 'click.codeblock.darkMode.color.background.default', + 'rgb-surface-code-body': 'click.codeblock.darkMode.color.background.default', + 'rgb-surface-inverted': 'click.button.basic.color.primary.background.default', + 'rgb-surface-inverted-hover': 'click.button.basic.color.primary.background.hover', + 'rgb-text-inverted': 'click.button.basic.color.primary.text.default', + 'rgb-surface-fixed': 'palette.neutral.0', + 'rgb-surface-fixed-hover': 'palette.slate.100', + 'rgb-text-fixed': 'palette.slate.900', + 'rgb-border-light': 'global.color.stroke.default', + 'rgb-border-medium': 'global.color.stroke.default', + 'rgb-border-medium-alt': 'global.color.stroke.default', + 'rgb-border-heavy': 'global.color.stroke.intense', + 'rgb-border-xheavy': 'palette.neutral.500', + 'rgb-border-destructive': 'palette.danger.300', + 'rgb-border-control': 'palette.neutral.500', + 'rgb-status-success': 'global.color.feedback.success.foreground', + 'rgb-status-success-subtle': 'global.color.feedback.success.background', + 'rgb-status-success-border': 'palette.success.800', + 'rgb-status-success-strong': 'palette.success.500', + 'rgb-status-info': 'global.color.feedback.info.foreground', + 'rgb-status-info-subtle': 'global.color.feedback.info.background', + 'rgb-status-info-border': 'palette.info.600', + 'rgb-status-info-strong': 'palette.info.300', + 'rgb-status-warning': 'global.color.feedback.warning.foreground', + 'rgb-status-warning-subtle': 'global.color.feedback.warning.background', + 'rgb-status-warning-border': 'palette.warning.700', + 'rgb-status-warning-strong': 'palette.warning.300', + 'rgb-status-error': 'global.color.feedback.danger.foreground', + 'rgb-status-error-subtle': 'global.color.feedback.danger.background', + 'rgb-status-error-border': 'palette.danger.700', + 'rgb-status-error-strong': 'palette.danger.300', + 'rgb-status-neutral': 'global.color.feedback.neutral.foreground', + 'rgb-status-neutral-subtle': 'global.color.feedback.neutral.background', + 'rgb-status-neutral-border': 'global.color.feedback.neutral.stroke', + 'rgb-status-verified': 'palette.info.300', + 'rgb-text-on-status': 'global.color.iconButton.badge.foreground', + 'rgb-brand-purple': 'palette.violet.300', + 'rgb-syntax-text': 'click.codeblock.darkMode.color.text.default', + 'rgb-syntax-comment': 'global.color.text.muted', + 'rgb-syntax-meta': 'palette.slate.400', + 'rgb-syntax-builtin': 'global.color.chart.default.sunrise', + 'rgb-syntax-keyword': 'palette.info.300', + 'rgb-syntax-string': 'palette.success.300', + 'rgb-syntax-attr': 'global.color.chart.default.fuchsia', + 'rgb-syntax-title': 'global.color.chart.default.orange', + 'rgb-series-1': 'global.color.chart.default.blue', + 'rgb-series-2': 'global.color.chart.default.orange', + 'rgb-series-3': 'global.color.chart.default.green', + 'rgb-series-4': 'global.color.chart.default.fuchsia', + 'rgb-series-5': 'global.color.chart.default.sunrise', + 'rgb-series-6': 'global.color.chart.default.violet', + 'rgb-series-7': 'global.color.chart.default.babyblue', + 'rgb-series-8': 'global.color.chart.default.teal', + 'rgb-switch-unchecked': 'palette.neutral.500', + 'rgb-presentation': 'global.color.background.default', + }, +}; + +/** Values the theme sets on purpose without a Click UI source, and why. */ +const unsourcedColors: Record>> = { + light: {}, + dark: { + 'rgb-surface-overlay': + 'Click UI dark dialog.color.opaqueBackground is a gray that leaves the dialog under 3:1', + }, +}; + +const appearanceSources: Partial> = { + controlRadius: 'border.radii.1', + radiusSm: 'border.radii.1', + radiusMd: 'border.radii.1', + radiusLg: 'border.radii.1', + surfaceRadius: 'border.radii.2', + radiusXl: 'border.radii.2', + radius2xl: 'border.radii.2', + largeSurfaceRadius: 'border.radii.3', + radius3xl: 'border.radii.3', + roundControlRadius: 'border.radii.full', + fontFamily: 'typography.font.families.regular', + monoFontFamily: 'typography.font.families.mono', + shadow2xs: 'shadow.5', + shadowXs: 'shadow.5', + shadowSm: 'shadow.5', + shadowMd: 'shadow.1', + shadowLg: 'shadow.1', + shadowXl: 'shadow.1', + shadow2xl: 'shadow.1', + elevationSurface: 'shadow.1', + controlHeight: 'click.genericMenu.panel.size.height', + motionFast: 'transition.duration.medium', + motionNormal: 'transition.duration.smooth', +}; + +/** Click UI's gray `lch()` stops, converted through CIE L* to an sRGB channel. Chroma must be zero. */ +function lchGray(lightness: number): number { + const y = lightness > 8 ? ((lightness + 16) / 116) ** 3 : lightness / 903.2962962; + const srgb = y <= 0.0031308 ? 12.92 * y : 1.055 * y ** (1 / 2.4) - 0.055; + return srgb * 255; +} + +function channel(value: string): number { + return value.endsWith('%') ? (parseFloat(value) / 100) * 255 : Number(value); +} + +function parseColor(value: string): Rgba { + const hex = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(value); + if (hex) { + return [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16), 1]; + } + const fn = /^(rgba?|lch)\((.+)\)$/.exec(value.trim()); + if (!fn) { + throw new Error(`Unsupported Click UI color: ${value}`); + } + const [channels, alpha = '1'] = fn[2].split('/').map((part) => part.trim()); + const parts = channels.split(/[\s,]+/).filter(Boolean); + const a = parts.length === 4 ? Number(parts[3]) : Number(alpha); + if (fn[1] !== 'lch') { + return [channel(parts[0]), channel(parts[1]), channel(parts[2]), a]; + } + if (Number(parts[1]) !== 0) { + throw new Error(`Only achromatic lch() is supported: ${value}`); + } + const gray = lchGray(Number(parts[0])); + return [gray, gray, gray, a]; +} + +const formatRgba = ([r, g, b, a]: Rgba) => + `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${Number(a.toFixed(3))})`; + +/** + * Click UI writes derived colors as rounded percentages, so a channel can land between two 8-bit + * values (`98.627%` is 251.5). A theme value within one step of the exact channel is a match. + */ +const sameRgb = (theme: string, source: Rgba) => { + const channels = theme.split(' ').map(Number); + return ( + channels.length === 3 && + channels.every((value, index) => Number.isFinite(value) && Math.abs(value - source[index]) < 1) + ); +}; + +/** Every color function in a shadow list, rewritten to one form so both sides compare. */ +const normalizeShadow = (value: string) => + value + .replace(/(rgba?|lch)\([^)]*\)/g, (color) => formatRgba(parseColor(color))) + .replace(/\s+/g, ' ') + .trim(); + +const firstFamily = (value: string) => value.split(',')[0].trim(); + +/** How a Click UI appearance token compares with the theme value that cites it. */ +function comparable(key: keyof IThemeAppearance, value: string): string { + if (key.startsWith('shadow') || key === 'elevationSurface') { + return normalizeShadow(value); + } + /** Click UI's mono tail names `"SFMono Regular"`, which no platform installs; the theme keeps + * the default theme's metric-matched tail, so only the face it leads with is Click UI's. */ + if (key === 'monoFontFamily') { + return firstFamily(value); + } + return value.replace(/;$/, '').trim(); +} + +describe('ClickHouse theme drift against Click UI', () => { + it('records the Click UI tag and commit the snapshot was taken from', () => { + expect(snapshot.source).toBe('https://github.com/ClickHouse/click-ui'); + expect(snapshot.tag).toMatch(/^v\d+\.\d+\.\d+$/); + expect(snapshot.commit).toMatch(/^[0-9a-f]{40}$/); + }); + + it.each(modes)('names a Click UI source for every %s color, or a reason it has none', (mode) => { + const colors = clickHouseTheme.modes[mode]?.colors ?? {}; + const missing = Object.keys(colors).filter( + (key) => + colorSources[mode][key as keyof IThemeRGB] === undefined && + unsourcedColors[mode][key as keyof IThemeRGB] === undefined, + ); + const stale = [ + ...Object.keys(colorSources[mode]), + ...Object.keys(unsourcedColors[mode]), + ].filter((key) => !(key in colors)); + + expect({ missing, stale }).toEqual({ missing: [], stale: [] }); + }); + + it.each(modes)('matches every sourced %s color to its Click UI token', (mode) => { + const colors = clickHouseTheme.modes[mode]?.colors ?? {}; + const drift = Object.entries(colorSources[mode]).flatMap(([key, token]) => { + const source = tokens[mode][token]; + if (source === undefined) { + return [`${key}: Click UI token ${token} is not in clickui.json`]; + } + const theme = colors[key as keyof IThemeRGB] ?? ''; + return sameRgb(theme, parseColor(source)) + ? [] + : [`${key}: theme ${theme}, Click UI ${token} is ${source}`]; + }); + + expect(drift).toEqual([]); + }); + + it.each(modes)('matches every %s shape, font and motion value to its Click UI token', (mode) => { + const appearance = clickHouseTheme.modes[mode]?.appearance ?? {}; + const unsourced = Object.keys(appearance).filter( + (key) => appearanceSources[key as keyof IThemeAppearance] === undefined, + ); + const drift = Object.entries(appearanceSources).flatMap(([name, token]) => { + const key = name as keyof IThemeAppearance; + const source = tokens[mode][token]; + if (source === undefined) { + return [`${key}: Click UI token ${token} is not in clickui.json`]; + } + const theme = appearance[key]; + if (theme === undefined) { + return [`${key}: the theme does not set it, Click UI ${token} is ${source}`]; + } + return comparable(key, theme) === comparable(key, source) + ? [] + : [`${key}: theme ${theme}, Click UI ${token} is ${source}`]; + }); + + expect({ unsourced, drift }).toEqual({ unsourced: [], drift: [] }); + }); + + it.each(modes)('keeps only the %s tokens the theme cites in the snapshot', (mode) => { + const cited = new Set([ + ...Object.values(colorSources[mode]), + ...Object.values(appearanceSources), + ]); + + expect(Object.keys(tokens[mode]).filter((token) => !cited.has(token))).toEqual([]); + }); +}); diff --git a/packages/client/src/theme/utils/applyTheme.ts b/packages/client/src/theme/utils/applyTheme.ts index d0978925f99..dacbd0a84a5 100644 --- a/packages/client/src/theme/utils/applyTheme.ts +++ b/packages/client/src/theme/utils/applyTheme.ts @@ -104,9 +104,17 @@ function mapAppearance(appearance: IThemeAppearance): Array<[string, string]> { ]); } +/** + * Marks a root whose applied theme names its own `rgb-ring-primary`. The app's global focus + * outline follows the ring only then: a theme that leaves the ring to the default resolves it to a + * mid gray that a dark surface cannot carry at 3:1, so it keeps the default outline instead. + */ +export const THEME_RING_ATTRIBUTE = 'data-theme-ring'; + export function clearAppliedTheme(root: HTMLElement = document.documentElement): void { themeOwnedProperties.forEach((property) => root.style.removeProperty(property)); root.removeAttribute('data-theme'); + root.removeAttribute(THEME_RING_ATTRIBUTE); } export function applyResolvedTheme(