+
{image.title}
)}
diff --git a/e2e/specs/mock/scenarios/drawer-edge-theme.spec.ts b/e2e/specs/mock/scenarios/drawer-edge-theme.spec.ts
new file mode 100644
index 00000000000..023fe5bb7c7
--- /dev/null
+++ b/e2e/specs/mock/scenarios/drawer-edge-theme.spec.ts
@@ -0,0 +1,72 @@
+import { expect, test } from '@playwright/test';
+import type { Page } from '@playwright/test';
+import { NEW_CHAT_PATH } from '../helpers';
+
+/**
+ * The mobile drawer's trailing edge reads `drawer-edge`. The stock themes keep it on the drawer
+ * fill in light and on `border-xheavy` in dark (pinned by the boundary scenarios in
+ * `mobile-drawer-controls.spec.ts`); this proves a theme that sets the role repaints the edge.
+ */
+
+type Mode = 'light' | 'dark';
+
+const DRAWER = '#mobile-drawer';
+const EDGE_THEME = {
+ version: 1,
+ name: 'drawer-edge-reference',
+ modes: {
+ light: { colors: { 'rgb-drawer-edge': '200 30 40' } },
+ dark: { colors: { 'rgb-drawer-edge': '30 200 40' } },
+ },
+};
+
+test.use({ viewport: { width: 390, height: 844 } });
+
+async function openDrawer(page: Page, mode: Mode) {
+ await page.addInitScript(
+ ([appearance, stored]) => {
+ localStorage.setItem('color-theme', appearance as string);
+ localStorage.removeItem('theme-colors');
+ localStorage.removeItem('theme-name');
+ localStorage.setItem('theme-definition', JSON.stringify(stored));
+ localStorage.setItem('theme-source', 'definition');
+ },
+ [mode, EDGE_THEME] as [string, unknown],
+ );
+ await page.goto(NEW_CHAT_PATH, { timeout: 15000 });
+ await expect(page.getByRole('textbox', { name: 'Message input' })).toBeVisible({
+ timeout: 30000,
+ });
+ await expect(page.locator('html')).toHaveAttribute('data-theme', EDGE_THEME.name);
+
+ const drawer = page.locator(DRAWER);
+ if (!(await drawer.getByTestId('close-sidebar-button').isVisible())) {
+ await page.getByTestId('header-open-sidebar-button').click();
+ }
+ await expect(drawer.getByTestId('close-sidebar-button')).toBeVisible();
+ await expect.poll(async () => (await drawer.boundingBox())?.x ?? -1, { timeout: 5000 }).toBe(0);
+ return drawer;
+}
+
+async function readEdge(page: Page, mode: Mode) {
+ const drawer = await openDrawer(page, mode);
+ return drawer.evaluate((node) => {
+ const style = getComputedStyle(node);
+ return { width: style.borderRightWidth, color: style.borderRightColor };
+ });
+}
+
+/** Each tag is written out whole: the runner finds a scenario by its literal tag. */
+test.describe('mobile drawer edge role', () => {
+ test('a theme that sets drawer-edge repaints the drawer edge in light @scenario:drawer-edge-follows-a-theme-light', async ({
+ page,
+ }) => {
+ expect(await readEdge(page, 'light')).toEqual({ width: '1px', color: 'rgb(200, 30, 40)' });
+ });
+
+ test('a theme that sets drawer-edge repaints the drawer edge in dark @scenario:drawer-edge-follows-a-theme-dark', async ({
+ page,
+ }) => {
+ expect(await readEdge(page, 'dark')).toEqual({ width: '1px', color: 'rgb(30, 200, 40)' });
+ });
+});
diff --git a/e2e/specs/mock/scenarios/mobile-drawer-controls.spec.ts b/e2e/specs/mock/scenarios/mobile-drawer-controls.spec.ts
index f5e52d3fff1..5b22200a325 100644
--- a/e2e/specs/mock/scenarios/mobile-drawer-controls.spec.ts
+++ b/e2e/specs/mock/scenarios/mobile-drawer-controls.spec.ts
@@ -133,7 +133,9 @@ async function readBoundary(page: Page) {
contrast(over(styles.edgeColor, styles.drawer), dimmed),
)
: 0;
- return { fill, edge, scrim: styles.scrim };
+ /** How far the edge stands off the drawer's own fill: 1 when it paints nothing new. */
+ const line = styles.edgeWidth >= 1 ? contrast(over(styles.edgeColor, styles.drawer), drawer) : 1;
+ return { fill, edge, line, scrim: styles.scrim };
}
test.describe('mobile drawer controls', () => {
@@ -303,13 +305,14 @@ test.describe('mobile drawer controls', () => {
test(title, async ({ page }) => {
await openDrawer(page, mode, definition, true);
await expect(page.locator(SCRIM)).toBeVisible();
- const { fill, edge } = await readBoundary(page);
+ const { fill, edge, line } = await readBoundary(page);
/** Either the fill against the scrim or the drawer's own edge has to carry it. */
expect(Math.max(fill, edge)).toBeGreaterThanOrEqual(BOUNDARY_CONTRAST);
if (mode === 'light') {
- /** The scrim does the work in light: no extra line on a surface that already reads. */
+ /** The scrim does the work in light: the `drawer-edge` role defaults to the drawer's own
+ * fill there, so no extra line shows on a surface that already reads. */
expect(fill).toBeGreaterThanOrEqual(BOUNDARY_CONTRAST);
- expect(edge).toBe(0);
+ expect(line).toBeCloseTo(1, 5);
}
});
}
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index 016268fb9f8..3119cb1160e 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -1035,7 +1035,7 @@
},
"client/src/components/Insights/InsightsView.tsx": {
"shadcn/no-restyle": {
- "count": 32
+ "count": 30
}
},
"client/src/components/MCP/CustomUserVarsSection.tsx": {
@@ -2061,9 +2061,6 @@
"shadcn/no-arbitrary-values": {
"count": 1
},
- "shadcn/no-raw-colors": {
- "count": 1
- },
"shadcn/no-restyle": {
"count": 56
},
diff --git a/packages/client/src/components/MultiSelect.spec.tsx b/packages/client/src/components/MultiSelect.spec.tsx
new file mode 100644
index 00000000000..11ef6fd007d
--- /dev/null
+++ b/packages/client/src/components/MultiSelect.spec.tsx
@@ -0,0 +1,37 @@
+import '@testing-library/jest-dom';
+import userEvent from '@testing-library/user-event';
+import { render, screen } from '@testing-library/react';
+import MultiSelect from './MultiSelect';
+
+function Picker({ surface }: { surface?: 'default' | 'widget' }) {
+ return (
+
undefined}
+ surface={surface}
+ />
+ );
+}
+
+async function openPopover() {
+ await userEvent.click(screen.getByRole('combobox'));
+ return screen.findByRole('dialog', { name: 'Agents' });
+}
+
+describe('MultiSelect surface', () => {
+ it('paints the popover with the shared menu surface by default', async () => {
+ render();
+ const popover = await openPopover();
+ expect(popover).toHaveClass('bg-surface-secondary');
+ expect(popover).not.toHaveClass('bg-chart-widget-surface');
+ });
+
+ it('paints the popover with the dashboard widget surface when asked', async () => {
+ render();
+ const popover = await openPopover();
+ expect(popover).toHaveClass('bg-chart-widget-surface');
+ expect(popover).not.toHaveClass('bg-surface-secondary');
+ });
+});
diff --git a/packages/client/src/components/MultiSelect.tsx b/packages/client/src/components/MultiSelect.tsx
index 92f8c166e04..91a2d56416f 100644
--- a/packages/client/src/components/MultiSelect.tsx
+++ b/packages/client/src/components/MultiSelect.tsx
@@ -40,6 +40,9 @@ interface MultiSelectProps {
selectClassName?: string;
selectIcon?: React.ReactNode;
popoverClassName?: string;
+ /** `widget` paints the popover in the dashboard widget surface (`chart-widget-surface`), for a
+ * picker that opens over chart widgets; `default` keeps the shared menu surface. */
+ surface?: 'default' | 'widget';
selectItemsClassName?: string;
selectedValues: T[];
setSelectedValues: (values: T[]) => void;
@@ -90,6 +93,7 @@ export default function MultiSelect({
labelClassName,
selectClassName,
popoverClassName,
+ surface = 'default',
selectItemsClassName,
selectedValues = [],
setSelectedValues,
@@ -180,6 +184,7 @@ export default function MultiSelect({
'bg-surface-secondary text-text-primary px-1.5 py-1 shadow-lg',
'border-border-light border',
'outline-hidden',
+ surface === 'widget' && 'bg-chart-widget-surface',
popoverClassName,
)}
>
diff --git a/packages/client/src/theme/README.md b/packages/client/src/theme/README.md
index 9d62a0e2f96..64643c51afe 100644
--- a/packages/client/src/theme/README.md
+++ b/packages/client/src/theme/README.md
@@ -310,6 +310,7 @@ function MyComponent() {
- `border-border-medium` - Medium border
- `border-border-heavy` - Heavy border
- `border-border-xheavy` - Extra heavy border
+- `border-drawer-edge` - The mobile drawer's trailing edge: the drawer's own fill in light, `border-xheavy` in dark
- `border-border-destructive` - Destructive action border
### Status Colors
diff --git a/packages/client/src/theme/defaults.css b/packages/client/src/theme/defaults.css
index 82ce064711f..4ea0ead542b 100644
--- a/packages/client/src/theme/defaults.css
+++ b/packages/client/src/theme/defaults.css
@@ -151,6 +151,8 @@ html {
--border-heavy-alpha: 1;
--border-xheavy: var(--gray-500);
--border-xheavy-alpha: 1;
+ /* The drawer's own fill: the scrim separates it in light. */
+ --drawer-edge: var(--surface-primary-alt);
--border-destructive: var(--red-600);
--border-control: var(--gray-200);
--border-field-focus: var(--focus-control);
@@ -394,6 +396,8 @@ html {
--border-medium: var(--gray-650);
--border-heavy: var(--gray-500);
--border-xheavy: var(--gray-400);
+ /* Dark draws a visible edge: the scrim and the drawer are both near-black. */
+ --drawer-edge: var(--border-xheavy);
--border-destructive: var(--red-500);
--border-control: var(--gray-800);
--border-field-focus: var(--focus-control);
diff --git a/packages/client/src/theme/defaults.spec.ts b/packages/client/src/theme/defaults.spec.ts
index 8c204f9edc5..5362e9d3461 100644
--- a/packages/client/src/theme/defaults.spec.ts
+++ b/packages/client/src/theme/defaults.spec.ts
@@ -68,7 +68,7 @@ describe.each([
const tokens = Object.keys(theme) as Array;
it('covers every registry color', () => {
- expect(tokens).toHaveLength(130);
+ expect(tokens).toHaveLength(131);
});
it('resolves every registry color to the runtime theme value', () => {
diff --git a/packages/client/src/theme/registry.spec.ts b/packages/client/src/theme/registry.spec.ts
index ce188228bec..dde227fb90d 100644
--- a/packages/client/src/theme/registry.spec.ts
+++ b/packages/client/src/theme/registry.spec.ts
@@ -384,6 +384,29 @@ describe('theme registry', () => {
expect(resolveTheme(theme, 'dark').colors['rgb-avatar-placeholder']).toBe('30 31 32');
});
+ it('keeps the drawer edge on the drawer fill in light and the heavy border in dark', () => {
+ const colors = { 'rgb-surface-primary-alt': '20 21 22', 'rgb-border-xheavy': '30 31 32' };
+ const theme = {
+ version: 1 as const,
+ name: 'legacy-drawer-edge',
+ modes: { light: { colors }, dark: { colors } },
+ };
+ const explicit = resolveTheme(
+ {
+ version: 1,
+ name: 'explicit-drawer-edge',
+ modes: {
+ dark: { colors: { 'rgb-border-xheavy': '30 31 32', 'rgb-drawer-edge': '1 2 3' } },
+ },
+ },
+ 'dark',
+ );
+
+ expect(resolveTheme(theme, 'light').colors['rgb-drawer-edge']).toBe('20 21 22');
+ expect(resolveTheme(theme, 'dark').colors['rgb-drawer-edge']).toBe('30 31 32');
+ expect(explicit.colors['rgb-drawer-edge']).toBe('1 2 3');
+ });
+
it('inks the default avatar in the primary text a theme sets, unless it sets the role', () => {
const inherited = resolveTheme(
{
diff --git a/packages/client/src/theme/registry.ts b/packages/client/src/theme/registry.ts
index 390b032fcbc..7b4a624ad2c 100644
--- a/packages/client/src/theme/registry.ts
+++ b/packages/client/src/theme/registry.ts
@@ -606,6 +606,18 @@ export function resolveTheme(theme: ThemeDefinition, mode: ThemeMode): ResolvedT
customColors?.['rgb-avatar-placeholder'] === undefined && avatarPlaceholderSource !== undefined
? { 'rgb-avatar-placeholder': avatarPlaceholderSource }
: {};
+ /**
+ * The mobile drawer drew its edge only in dark, in `border-xheavy`; in light the drawer's own
+ * fill. A theme that repaints the role its mode used keeps that edge.
+ */
+ const drawerEdgeSource =
+ mode === 'dark'
+ ? customColors?.['rgb-border-xheavy']
+ : customColors?.['rgb-surface-primary-alt'];
+ const drawerEdgeFallback: Partial =
+ customColors?.['rgb-drawer-edge'] === undefined && drawerEdgeSource !== undefined
+ ? { 'rgb-drawer-edge': drawerEdgeSource }
+ : {};
const chartWidgetSurfaceFallback: IThemeRGB =
customColors?.['rgb-chart-widget-surface'] === undefined &&
customColors?.['rgb-surface-primary'] !== undefined
@@ -711,6 +723,7 @@ export function resolveTheme(theme: ThemeDefinition, mode: ThemeMode): ResolvedT
...textMutedFallback,
...proseLinkFallback,
...avatarPlaceholderFallback,
+ ...drawerEdgeFallback,
...chartWidgetSurfaceFallback,
...chartWidgetStrokeFallback,
...switchThumbFallback,
diff --git a/packages/client/src/theme/themes/clickhouse.ts b/packages/client/src/theme/themes/clickhouse.ts
index f3a80fcd1ce..da1b57a1d94 100644
--- a/packages/client/src/theme/themes/clickhouse.ts
+++ b/packages/client/src/theme/themes/clickhouse.ts
@@ -105,6 +105,7 @@ export const clickHouseLightTheme: IThemeRGB = {
'rgb-border-medium': '230 231 233', // #e6e7e9 (stroke.default)
'rgb-border-medium-alt': '230 231 233', // #e6e7e9 (stroke.default)
'rgb-border-heavy': '179 182 189', // #b3b6bd (stroke.intense)
+ 'rgb-drawer-edge': '246 247 250', // #f6f7fa (background.split, the drawer's own fill)
'rgb-border-xheavy': '128 134 145', // #808691 (palette.slate.500, two steps past stroke.intense #b3b6bd (2.03:1 on white))
'rgb-border-destructive': '193 0 0', // #c10000 (palette.danger.600)
'rgb-border-control': '128 134 145', // #808691 (palette.slate.500, 3.42:1 on background.muted)
@@ -266,6 +267,7 @@ export const clickHouseDarkTheme: IThemeRGB = {
'rgb-border-medium': '50 50 50', // #323232 (stroke.default)
'rgb-border-medium-alt': '50 50 50', // #323232 (stroke.default)
'rgb-border-heavy': '65 65 65', // #414141 (stroke.intense)
+ 'rgb-drawer-edge': '128 128 128', // #808080 (palette.neutral.500, matching border-xheavy)
'rgb-border-xheavy': '128 128 128', // #808080 (palette.neutral.500, three steps past stroke.intense #414141 (1.62:1 on the canvas))
'rgb-border-destructive': '255 117 117', // #ff7575 (palette.danger.300)
'rgb-border-control': '128 128 128', // #808080 (palette.neutral.500, 3.73:1 on background.muted)
diff --git a/packages/client/src/theme/themes/clickui.spec.ts b/packages/client/src/theme/themes/clickui.spec.ts
index 7902188b958..bdaa0c83f17 100644
--- a/packages/client/src/theme/themes/clickui.spec.ts
+++ b/packages/client/src/theme/themes/clickui.spec.ts
@@ -118,6 +118,7 @@ const colorSources: Record>>
'rgb-border-medium-alt': 'global.color.stroke.default',
'rgb-border-heavy': 'global.color.stroke.intense',
'rgb-border-xheavy': 'palette.slate.500',
+ 'rgb-drawer-edge': 'global.color.background.split',
'rgb-border-destructive': 'palette.danger.600',
'rgb-border-control': 'palette.slate.500',
'rgb-border-field-focus': 'click.field.color.stroke.active',
@@ -247,6 +248,7 @@ const colorSources: Record>>
'rgb-border-medium-alt': 'global.color.stroke.default',
'rgb-border-heavy': 'global.color.stroke.intense',
'rgb-border-xheavy': 'palette.neutral.500',
+ 'rgb-drawer-edge': 'palette.neutral.500',
'rgb-border-destructive': 'palette.danger.300',
'rgb-border-control': 'palette.neutral.500',
'rgb-border-field-focus': 'click.field.color.stroke.active',
diff --git a/packages/client/src/theme/themes/dark.ts b/packages/client/src/theme/themes/dark.ts
index 7918daaa17c..bdacfb157de 100644
--- a/packages/client/src/theme/themes/dark.ts
+++ b/packages/client/src/theme/themes/dark.ts
@@ -80,6 +80,7 @@ export const darkTheme: IThemeRGB = {
'rgb-border-medium-alt': '57 57 57', // #393939 (gray-650)
'rgb-border-heavy': '89 89 89', // #595959 (gray-500)
'rgb-border-xheavy': '153 150 150', // #999696 (gray-400)
+ 'rgb-drawer-edge': '153 150 150', // #999696 (gray-400, matching border-xheavy)
'rgb-border-destructive': '239 68 68', // #ef4444 (red-500)
'rgb-border-control': '33 33 33', // #212121 (gray-800), the stock field edge
'rgb-border-field-focus': '236 236 236', // #ececec (gray-100, matching focus-control)
diff --git a/packages/client/src/theme/themes/default.ts b/packages/client/src/theme/themes/default.ts
index 362f8549f0f..c43a32c8602 100644
--- a/packages/client/src/theme/themes/default.ts
+++ b/packages/client/src/theme/themes/default.ts
@@ -80,6 +80,7 @@ export const defaultTheme: IThemeRGB = {
'rgb-border-medium-alt': '205 205 205', // #cdcdcd (gray-300)
'rgb-border-heavy': '153 150 150', // #999696 (gray-400)
'rgb-border-xheavy': '89 89 89', // #595959 (gray-500)
+ 'rgb-drawer-edge': '247 247 248', // #f7f7f8 (gray-50, the drawer's own fill)
'rgb-border-destructive': '220 38 38', // #dc2626 (red-600)
'rgb-border-control': '227 227 227', // #e3e3e3 (gray-200), the stock field edge
'rgb-border-field-focus': '33 33 33', // #212121 (gray-800, matching focus-control)
diff --git a/packages/client/src/theme/themes/highContrast.ts b/packages/client/src/theme/themes/highContrast.ts
index ae96141a00c..34a515dff59 100644
--- a/packages/client/src/theme/themes/highContrast.ts
+++ b/packages/client/src/theme/themes/highContrast.ts
@@ -116,6 +116,7 @@ export const highContrastLightTheme: IThemeRGB = {
'rgb-border-medium-alt': '0 0 0', // #000000
'rgb-border-heavy': '0 0 0', // #000000
'rgb-border-xheavy': '0 0 0', // #000000
+ 'rgb-drawer-edge': '255 255 255', // #ffffff (the drawer's own fill)
'rgb-border-destructive': '161 0 0', // #a10000
'rgb-border-control': '0 0 0', // #000000
'rgb-border-field-focus': '0 0 0', // #000000 (matching focus-control)
@@ -295,6 +296,7 @@ export const highContrastDarkTheme: IThemeRGB = {
'rgb-border-medium-alt': '255 255 255', // #ffffff
'rgb-border-heavy': '255 255 255', // #ffffff
'rgb-border-xheavy': '255 255 255', // #ffffff
+ 'rgb-drawer-edge': '255 255 255', // #ffffff (matching border-xheavy)
'rgb-border-destructive': '255 143 143', // #ff8f8f
'rgb-border-control': '255 255 255', // #ffffff
'rgb-border-field-focus': '255 255 255', // #ffffff (matching focus-control)
diff --git a/packages/client/src/theme/tokens.css b/packages/client/src/theme/tokens.css
index d8119d12bb6..0ee78369e1f 100644
--- a/packages/client/src/theme/tokens.css
+++ b/packages/client/src/theme/tokens.css
@@ -116,6 +116,7 @@
--color-border-medium-alt: rgb(var(--border-medium-alt));
--color-border-heavy: rgb(var(--border-heavy) / var(--border-heavy-alpha, 1));
--color-border-xheavy: rgb(var(--border-xheavy) / var(--border-xheavy-alpha, 1));
+ --color-drawer-edge: rgb(var(--drawer-edge));
--color-border-destructive: rgb(var(--border-destructive));
--color-border-control: rgb(var(--border-control));
/* Falls back to the focus ring color, for a stylesheet that predates it. */
diff --git a/packages/client/src/theme/types/index.ts b/packages/client/src/theme/types/index.ts
index 892ad9df8e9..238e5a28182 100644
--- a/packages/client/src/theme/types/index.ts
+++ b/packages/client/src/theme/types/index.ts
@@ -93,6 +93,11 @@ export interface IThemeRGB {
'rgb-border-medium-alt'?: string;
'rgb-border-heavy'?: string;
'rgb-border-xheavy'?: string;
+ /** The mobile drawer's trailing edge. Its light default is the drawer's own fill, since the
+ * scrim already separates it there; dark mode draws a visible edge because the scrim and the
+ * drawer are both near-black. Follows `rgb-surface-primary-alt` (light) and
+ * `rgb-border-xheavy` (dark) in a theme that does not set it. */
+ 'rgb-drawer-edge'?: string;
'rgb-border-destructive'?: string;
/** The boundary of a form control (field, select trigger, OTP slot). Owes the
* 3:1 non-text floor on every canvas, so it is kept apart from the separator
@@ -288,6 +293,7 @@ export interface IThemeVariables {
'--border-heavy': string;
'--border-heavy-alpha': string;
'--border-xheavy': string;
+ '--drawer-edge': string;
'--border-xheavy-alpha': string;
'--border-destructive': string;
'--border-control': string;
@@ -427,6 +433,7 @@ export interface IThemeColors {
'border-medium-alt'?: string;
'border-heavy'?: string;
'border-xheavy'?: string;
+ 'drawer-edge'?: string;
'border-destructive'?: string;
'border-control'?: string;
'border-field-focus'?: string;
diff --git a/packages/data-provider/src/theme.ts b/packages/data-provider/src/theme.ts
index 4bb26d7ea5b..050c1d4180d 100644
--- a/packages/data-provider/src/theme.ts
+++ b/packages/data-provider/src/theme.ts
@@ -78,6 +78,7 @@ export const themeColorTokens = Object.freeze([
'rgb-border-medium-alt',
'rgb-border-heavy',
'rgb-border-xheavy',
+ 'rgb-drawer-edge',
'rgb-border-destructive',
'rgb-border-control',
'rgb-border-field-focus',