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
3 changes: 2 additions & 1 deletion client/src/components/Insights/InsightsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
18 changes: 9 additions & 9 deletions client/src/components/Prompts/forms/PromptForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -114,10 +114,10 @@ const VersionsPanel = React.memo(
{!isLoadingPrompts && prompts.length > 0 && (
<>
<div className="mb-2 flex items-center justify-between">
<h2 className="text-sm font-medium text-text-secondary">
<h2 className="text-text-secondary text-sm font-medium">
{localize('com_ui_versions')}
</h2>
<span className="flex size-5 items-center justify-center rounded-full bg-surface-tertiary text-xs font-medium text-text-secondary">
<span className="bg-surface-tertiary text-text-secondary flex size-5 items-center justify-center rounded-full text-xs font-medium">
{prompts.length}
</span>
</div>
Expand Down Expand Up @@ -474,7 +474,7 @@ const PromptForm = ({ promptId: promptIdProp }: { promptId?: string }) => {
<div className="flex-1 overflow-hidden px-4">
{/* Mobile Actions Row */}
{!isLoadingGroup && group && isSmallScreen && (
<div className="mb-3 mt-2 flex items-center justify-between gap-2">
<div className="mt-2 mb-3 flex items-center justify-between gap-2">
<OpenSidebar />
<HeaderActions
group={group}
Expand All @@ -486,7 +486,7 @@ const PromptForm = ({ promptId: promptIdProp }: { promptId?: string }) => {
</div>
)}
{/* Header: Title + Actions */}
<div className="mb-3 mt-2 flex items-center justify-between gap-2">
<div className="mt-2 mb-3 flex items-center justify-between gap-2">
{isLoadingGroup ? (
<Skeleton className="h-9 w-48" />
) : (
Expand Down Expand Up @@ -563,7 +563,7 @@ const PromptForm = ({ promptId: promptIdProp }: { promptId?: string }) => {

{/* Versions Sidebar - Advanced Mode Only */}
{editorMode === PromptsEditorMode.ADVANCED && (
<div className="hidden w-72 shrink-0 border-l border-border-medium lg:block xl:w-80">
<div className="border-border-medium hidden w-72 shrink-0 border-l lg:block xl:w-80">
<VersionsPanel
group={group}
prompts={prompts}
Expand All @@ -582,7 +582,7 @@ const PromptForm = ({ promptId: promptIdProp }: { promptId?: string }) => {
<div
aria-hidden={!showSidePanel}
className={cn(
'fixed inset-0 z-[100] bg-black/20 lg:hidden',
'bg-scrim fixed inset-0 z-[100] lg:hidden',
showSidePanel ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0',
)}
style={{ transition: 'opacity 300ms cubic-bezier(0.2, 0, 0, 1)' }}
Expand All @@ -600,7 +600,7 @@ const PromptForm = ({ promptId: promptIdProp }: { promptId?: string }) => {
<div
ref={sidePanelRef}
className={cn(
'fixed right-0 top-0 z-[110] flex h-full flex-col border-l border-border-medium bg-surface-primary-alt shadow-xl lg:hidden',
'border-border-medium bg-surface-primary-alt fixed top-0 right-0 z-[110] flex h-full flex-col border-l shadow-xl lg:hidden',
showSidePanel ? 'translate-x-0' : 'translate-x-full',
)}
style={{
Expand All @@ -613,7 +613,7 @@ const PromptForm = ({ promptId: promptIdProp }: { promptId?: string }) => {
inert={!showSidePanel ? '' : undefined}
>
<div className="flex items-center justify-between px-4 py-2">
<h2 className="text-sm font-semibold text-text-primary">
<h2 className="text-text-primary text-sm font-semibold">
{localize('com_ui_versions')}
</h2>
<Button
Expand Down
5 changes: 3 additions & 2 deletions client/src/components/UnifiedSidebar/UnifiedSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,9 @@ function UnifiedSidebar({
'bg-surface-primary-alt text-text-primary absolute inset-y-0 left-0 flex touch-pan-y touch-pinch-zoom flex-col',
/** In dark mode the scrim and the drawer are both near-black, and no
* scrim opacity separates them by 3:1, so the drawer draws its own
* edge. Light palettes get that separation from the scrim. */
'dark:border-border-xheavy dark:border-r',
* edge. Light palettes get that separation from the scrim, so the
* `drawer-edge` role defaults to the drawer's own fill there. */
'border-drawer-edge border-r',
expanded ? 'translate-x-0' : '-translate-x-full',
)}
style={{
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Web/Sources.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ function ImageItem({ image }: { image: ImageResult }) {
className="size-full object-cover"
/>
{image.title && (
<div className="absolute right-0 bottom-0 left-0 w-full border-none bg-gray-900/80 p-1 text-xs font-medium text-white backdrop-blur-xs">
<div className="bg-surface-media-overlay/80 text-text-on-media absolute right-0 bottom-0 left-0 w-full border-none p-1 text-xs font-medium backdrop-blur-xs">
<span className="truncate">{image.title}</span>
</div>
)}
Expand Down
72 changes: 72 additions & 0 deletions e2e/specs/mock/scenarios/drawer-edge-theme.spec.ts
Original file line number Diff line number Diff line change
@@ -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)' });
});
});
11 changes: 7 additions & 4 deletions e2e/specs/mock/scenarios/mobile-drawer-controls.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
}
});
}
Expand Down
5 changes: 1 addition & 4 deletions eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -1035,7 +1035,7 @@
},
"client/src/components/Insights/InsightsView.tsx": {
"shadcn/no-restyle": {
"count": 32
"count": 30
}
},
"client/src/components/MCP/CustomUserVarsSection.tsx": {
Expand Down Expand Up @@ -2061,9 +2061,6 @@
"shadcn/no-arbitrary-values": {
"count": 1
},
"shadcn/no-raw-colors": {
"count": 1
},
"shadcn/no-restyle": {
"count": 56
},
Expand Down
37 changes: 37 additions & 0 deletions packages/client/src/components/MultiSelect.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<MultiSelect
label="Agents"
items={['alpha', 'beta']}
selectedValues={[]}
setSelectedValues={() => 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(<Picker />);
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(<Picker surface="widget" />);
const popover = await openPopover();
expect(popover).toHaveClass('bg-chart-widget-surface');
expect(popover).not.toHaveClass('bg-surface-secondary');
});
});
5 changes: 5 additions & 0 deletions packages/client/src/components/MultiSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ interface MultiSelectProps<T extends string> {
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;
Expand Down Expand Up @@ -90,6 +93,7 @@ export default function MultiSelect<T extends string>({
labelClassName,
selectClassName,
popoverClassName,
surface = 'default',
selectItemsClassName,
selectedValues = [],
setSelectedValues,
Expand Down Expand Up @@ -180,6 +184,7 @@ export default function MultiSelect<T extends string>({
'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,
)}
>
Expand Down
1 change: 1 addition & 0 deletions packages/client/src/theme/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/client/src/theme/defaults.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/theme/defaults.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ describe.each([
const tokens = Object.keys(theme) as Array<keyof IThemeRGB>;

it('covers every registry color', () => {
expect(tokens).toHaveLength(130);
expect(tokens).toHaveLength(131);
});

it('resolves every registry color to the runtime theme value', () => {
Expand Down
23 changes: 23 additions & 0 deletions packages/client/src/theme/registry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
13 changes: 13 additions & 0 deletions packages/client/src/theme/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IThemeRGB> =
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
Expand Down Expand Up @@ -711,6 +723,7 @@ export function resolveTheme(theme: ThemeDefinition, mode: ThemeMode): ResolvedT
...textMutedFallback,
...proseLinkFallback,
...avatarPlaceholderFallback,
...drawerEdgeFallback,
...chartWidgetSurfaceFallback,
...chartWidgetStrokeFallback,
...switchThumbFallback,
Expand Down
2 changes: 2 additions & 0 deletions packages/client/src/theme/themes/clickhouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading