From 470367415a8d6b9c7f0738bc5d1696cc219419c9 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 1 Oct 2026 18:13:13 +0200
Subject: [PATCH 1/4] feat: Give the Mobile Drawer Edge a Role and Move the
Last Page Colours Onto Roles
The mobile drawer drew its edge with dark-only border utilities, the web source caption with gray-900 and white, the prompt panel's mobile backdrop with black at 20%, and the Insights agent picker popover with a dark-only override, so no theme could reach them.
drawer-edge paints the drawer's trailing edge: the drawer's own fill in light, where the scrim already separates it, and border-xheavy in dark, so dark mode keeps today's edge; a theme that sets only those two roles keeps the edge its mode drew. The source caption reads the media overlay roles, the prompt panel backdrop takes the page scrim the mobile drawer uses, and MultiSelect gains a widget surface that the Insights picker uses in place of its dark override.
---
.../src/components/Insights/InsightsView.tsx | 3 +-
.../components/Prompts/forms/PromptForm.tsx | 18 ++++-----
.../UnifiedSidebar/UnifiedSidebar.tsx | 5 ++-
client/src/components/Web/Sources.tsx | 2 +-
eslint-suppressions.json | 5 +--
.../src/components/MultiSelect.spec.tsx | 37 +++++++++++++++++++
.../client/src/components/MultiSelect.tsx | 5 +++
packages/client/src/theme/README.md | 1 +
packages/client/src/theme/defaults.css | 4 ++
packages/client/src/theme/defaults.spec.ts | 2 +-
packages/client/src/theme/registry.spec.ts | 23 ++++++++++++
packages/client/src/theme/registry.ts | 13 +++++++
.../client/src/theme/themes/clickhouse.ts | 2 +
.../client/src/theme/themes/clickui.spec.ts | 2 +
packages/client/src/theme/themes/dark.ts | 1 +
packages/client/src/theme/themes/default.ts | 1 +
.../client/src/theme/themes/highContrast.ts | 2 +
packages/client/src/theme/tokens.css | 1 +
packages/client/src/theme/types/index.ts | 7 ++++
packages/data-provider/src/theme.ts | 1 +
20 files changed, 117 insertions(+), 18 deletions(-)
create mode 100644 packages/client/src/components/MultiSelect.spec.tsx
diff --git a/client/src/components/Insights/InsightsView.tsx b/client/src/components/Insights/InsightsView.tsx
index 19c82384d20..16ae62dba95 100644
--- a/client/src/components/Insights/InsightsView.tsx
+++ b/client/src/components/Insights/InsightsView.tsx
@@ -769,7 +769,8 @@ export default function InsightsView() {
className="w-full min-w-0 sm:w-72"
selectClassName="h-8 w-full rounded border border-border-medium bg-surface-tertiary px-3 py-1 shadow-none hover:border-border-heavy data-[state=open]:border-border-heavy dark:hover:bg-chart-widget-stroke dark:data-[state=open]:bg-chart-widget-stroke"
itemClassName="rounded-none px-4 py-1.5"
- popoverClassName="max-h-80 rounded border-border-medium bg-surface-primary px-0 py-2 dark:bg-chart-widget-surface"
+ popoverClassName="max-h-80 rounded border-border-medium px-0 py-2"
+ surface="widget"
renderSelectedValues={(values) => {
if (values.length === 0) {
return localize('com_insights_no_agents_selected');
diff --git a/client/src/components/Prompts/forms/PromptForm.tsx b/client/src/components/Prompts/forms/PromptForm.tsx
index 9470be558c2..1af1b1a9378 100644
--- a/client/src/components/Prompts/forms/PromptForm.tsx
+++ b/client/src/components/Prompts/forms/PromptForm.tsx
@@ -76,7 +76,7 @@ const VersionsPanel = React.memo(
className={cn(
'w-full gap-1.5 transition-all duration-200',
isProductionVersion &&
- 'border border-status-success-border bg-status-success-subtle text-status-success hover:bg-status-success-subtle',
+ 'border-status-success-border bg-status-success-subtle text-status-success hover:bg-status-success-subtle border',
)}
onClick={() => {
if (!selectedPrompt) {
@@ -114,10 +114,10 @@ const VersionsPanel = React.memo(
{!isLoadingPrompts && prompts.length > 0 && (
<>
-
+
{localize('com_ui_versions')}
-
+
{prompts.length}
@@ -474,7 +474,7 @@ const PromptForm = ({ promptId: promptIdProp }: { promptId?: string }) => {
{/* Mobile Actions Row */}
{!isLoadingGroup && group && isSmallScreen && (
-
+
{
)}
{/* Header: Title + Actions */}
-
+
{isLoadingGroup ? (
) : (
@@ -563,7 +563,7 @@ const PromptForm = ({ promptId: promptIdProp }: { promptId?: string }) => {
{/* Versions Sidebar - Advanced Mode Only */}
{editorMode === PromptsEditorMode.ADVANCED && (
-
+
{
{
{
inert={!showSidePanel ? '' : undefined}
>
-
+
{localize('com_ui_versions')}
{image.title && (
-
+
{image.title}
)}
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..bb8fbd80dfa 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 =
+ 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',
From b102a80c729b049efb500215760172a7a723dfe3 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 1 Oct 2026 18:16:46 +0200
Subject: [PATCH 2/4] test: Pin the Drawer Edge Role in Both Modes and Under a
Theme
---
.../mock/scenarios/drawer-edge-theme.spec.ts | 67 +++++++++++++++++++
.../scenarios/mobile-drawer-controls.spec.ts | 11 +--
2 files changed, 74 insertions(+), 4 deletions(-)
create mode 100644 e2e/specs/mock/scenarios/drawer-edge-theme.spec.ts
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..fe6ed368f55
--- /dev/null
+++ b/e2e/specs/mock/scenarios/drawer-edge-theme.spec.ts
@@ -0,0 +1,67 @@
+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;
+}
+
+test.describe('mobile drawer edge role', () => {
+ for (const [mode, color] of [
+ ['light', 'rgb(200, 30, 40)'],
+ ['dark', 'rgb(30, 200, 40)'],
+ ] as const) {
+ test(`a theme that sets drawer-edge repaints the drawer edge in ${mode} @scenario:drawer-edge-follows-a-theme-${mode}`, async ({
+ page,
+ }) => {
+ const drawer = await openDrawer(page, mode);
+ const edge = await drawer.evaluate((node) => {
+ const style = getComputedStyle(node);
+ return { width: style.borderRightWidth, color: style.borderRightColor };
+ });
+ expect(edge).toEqual({ width: '1px', color });
+ });
+ }
+});
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);
}
});
}
From e38094dce9e3d52a1c3e2b932b2dc262843e8305 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 1 Oct 2026 18:28:20 +0200
Subject: [PATCH 3/4] test: Write the Drawer Edge Scenario Tags Out Whole
---
.../mock/scenarios/drawer-edge-theme.spec.ts | 35 +++++++++++--------
1 file changed, 20 insertions(+), 15 deletions(-)
diff --git a/e2e/specs/mock/scenarios/drawer-edge-theme.spec.ts b/e2e/specs/mock/scenarios/drawer-edge-theme.spec.ts
index fe6ed368f55..023fe5bb7c7 100644
--- a/e2e/specs/mock/scenarios/drawer-edge-theme.spec.ts
+++ b/e2e/specs/mock/scenarios/drawer-edge-theme.spec.ts
@@ -48,20 +48,25 @@ async function openDrawer(page: Page, mode: Mode) {
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', () => {
- for (const [mode, color] of [
- ['light', 'rgb(200, 30, 40)'],
- ['dark', 'rgb(30, 200, 40)'],
- ] as const) {
- test(`a theme that sets drawer-edge repaints the drawer edge in ${mode} @scenario:drawer-edge-follows-a-theme-${mode}`, async ({
- page,
- }) => {
- const drawer = await openDrawer(page, mode);
- const edge = await drawer.evaluate((node) => {
- const style = getComputedStyle(node);
- return { width: style.borderRightWidth, color: style.borderRightColor };
- });
- expect(edge).toEqual({ width: '1px', color });
- });
- }
+ 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)' });
+ });
});
From bda163c5ffc63b39d708424ab88bce4b77b5fdd7 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 1 Oct 2026 18:43:08 +0200
Subject: [PATCH 4/4] fix: Type the Drawer Edge Fallback So the Theme Resolver
Stays Checkable
resolveTheme spreads every role fallback into one object literal, and each untyped conditional spread doubles the union the checker builds. One more pushed tsc for packages/client past CI's 6 GB heap. Typing the fallback as Partial keeps it from adding to that union; a full check now peaks at about 4 GB.
---
packages/client/src/theme/registry.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/client/src/theme/registry.ts b/packages/client/src/theme/registry.ts
index bb8fbd80dfa..7b4a624ad2c 100644
--- a/packages/client/src/theme/registry.ts
+++ b/packages/client/src/theme/registry.ts
@@ -614,7 +614,7 @@ export function resolveTheme(theme: ThemeDefinition, mode: ThemeMode): ResolvedT
mode === 'dark'
? customColors?.['rgb-border-xheavy']
: customColors?.['rgb-surface-primary-alt'];
- const drawerEdgeFallback =
+ const drawerEdgeFallback: Partial =
customColors?.['rgb-drawer-edge'] === undefined && drawerEdgeSource !== undefined
? { 'rgb-drawer-edge': drawerEdgeSource }
: {};