Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions client/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -2465,18 +2465,31 @@ 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 {
outline: 2px solid #fff;
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);
Expand Down
19 changes: 19 additions & 0 deletions e2e/specs/mock/scenarios/clickhouse-theme-shape.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}) => {
Expand Down
191 changes: 191 additions & 0 deletions e2e/specs/mock/scenarios/focus-outline.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Outline> {
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]));
}
});
});
67 changes: 67 additions & 0 deletions packages/client/src/theme/context/ThemeProvider.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});

Expand Down Expand Up @@ -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(
<ThemeProvider
initialTheme="light"
themeDefinition={{
version: 1,
name: 'ringed',
modes: {
light: { colors: { 'rgb-ring-primary': '10 20 30' } },
dark: { colors: { 'rgb-accent-primary': '1 2 3' } },
},
}}
>
<Controls />
</ThemeProvider>,
);

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(
<ThemeProvider
initialTheme="dark"
themeName="legacy"
themeRGB={{ 'rgb-ring-primary': '1 2 3' }}
>
<Controls />
</ThemeProvider>,
);

await waitFor(() => {
expect(document.documentElement).toHaveAttribute('data-theme-ring');
});

rerender(
<ThemeProvider
initialTheme="dark"
themeName="legacy"
themeRGB={{ 'rgb-accent-primary': '1 2 3' }}
>
<Controls />
</ThemeProvider>,
);

await waitFor(() => {
expect(document.documentElement).not.toHaveAttribute('data-theme-ring');
});
expect(document.documentElement.dataset.theme).toBe('legacy');
});
});
});
14 changes: 13 additions & 1 deletion packages/client/src/theme/context/ThemeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -48,6 +52,7 @@ type ThemeDOMSnapshot = {
properties: Map<string, { value: string; priority: string }>;
colorScheme: { value: string; priority: string };
dataTheme: string | null;
themeRing: boolean;
};

type ThemeClassSnapshot = {
Expand Down Expand Up @@ -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 => {
Expand All @@ -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({
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading