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
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ interface DiffRowProps {
const DiffRow = memo(function DiffRow({ type, text, num, hasLineNumbers }: DiffRowProps) {
if (type === 'hunk') {
if (!text) {
return <div className="border-border-inset mx-3 my-1.5 border-t" />;
return <div className="border-border-light mx-3 my-1.5 border-t" />;
}
return <div className="text-text-tertiary px-3 py-0.5 text-[11px] select-none">{text}</div>;
}
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Chat/Messages/Content/WebSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ export default function WebSearch({
className={cn(
'hover:bg-surface-hover flex gap-2.5 px-3 py-2 transition-colors',
snippet ? 'items-start' : 'items-center',
i > 0 && 'border-border-inset border-t',
i > 0 && 'border-border-light border-t',
)}
>
<FaviconImage
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/Messages/Content/CodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
return (
<div
ref={containerRef}
className="border-border-light relative w-full overflow-hidden rounded-xl border text-xs"
className="border-border-inset relative w-full overflow-hidden rounded-xl border text-xs"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={handleFocus}
Expand Down Expand Up @@ -138,7 +138,7 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
/>
{allowExecution === true && toolCalls && toolCalls.length > 0 && (
<>
<div className="border-border-light bg-surface-code border-t p-4 text-xs">
<div className="border-border-inset bg-surface-code border-t p-4 text-xs">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route the multi-result divider through the inset role

When code execution has more than one result, this newly inset output section is immediately followed by ResultSwitcher, whose root still uses border-border-light for its top border. Consequently, a theme such as ClickHouse with insetBorderAlpha: '0' still draws an internal rule at the bottom of the output, so the code block is only quiet for single-result executions. Change that divider to the inset border role as well and cover the multi-result state.

AGENTS.md reference: AGENTS.md:L142-L151

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 877eb18: the result switcher divider draws border-border-inset, so a multi-result code block is quiet end to end.

<div className="text-text-secondary mb-1 text-[10px] font-medium tracking-wide uppercase">
{localize('com_ui_output')}
</div>
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Messages/Content/ResultSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export default function ResultSwitcher({
return (
<nav
aria-label={localize('com_ui_navigate_results')}
className="border-border-light bg-surface-code flex items-center justify-center gap-1.5 border-t px-3 py-1.5 text-xs"
className="border-border-inset bg-surface-code flex items-center justify-center gap-1.5 border-t px-3 py-1.5 text-xs"
>
<button
type="button"
Expand Down
15 changes: 11 additions & 4 deletions client/src/components/Prompts/display/PromptVariables.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Variable, ChevronRight } from 'lucide-react';
import { specialVariables } from 'librechat-data-provider';
import type { TSpecialVarLabel } from 'librechat-data-provider';
import { getSpecialVariableIcon } from '~/components/Prompts/utils';
import { extractUniqueVariables } from '~/utils';
import { extractUniqueVariables, cn } from '~/utils';
import { useLocalize } from '~/hooks';

interface ParsedVariable {
Expand Down Expand Up @@ -107,8 +107,15 @@ const SimpleVariableChip = ({ parsed }: { parsed: ParsedVariable }) => (
</span>
);

const PromptVariables = ({ promptText }: { promptText: string }) => {
const PromptVariables = ({
promptText,
inset = false,
}: {
promptText: string;
inset?: boolean;
}) => {
const localize = useLocalize();
const frame = inset ? 'border-border-inset-medium' : 'border-border-medium';

const variables = useMemo(() => {
return extractUniqueVariables(promptText || '');
Expand Down Expand Up @@ -138,8 +145,8 @@ const PromptVariables = ({ promptText }: { promptText: string }) => {
}

return (
<div className="border-border-medium overflow-hidden rounded-xl border">
<header className="border-border-medium flex items-center justify-between border-b p-3">
<div className={cn(frame, 'overflow-hidden rounded-xl border')}>
<header className={cn(frame, 'flex items-center justify-between border-b p-3')}>
<div className="flex items-center gap-2">
<Variable className="text-text-secondary size-4" aria-hidden="true" />
<h4 className="text-text-primary text-sm font-semibold">
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Prompts/fields/Command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const Command = ({
}

return (
<div className="border-border-medium rounded-xl border">
<div className="border-border-inset-medium rounded-xl border">
<label htmlFor={commandId} className="text-text-secondary block px-4 pt-2 text-sm md:hidden">
{localize('com_ui_command_placeholder')}
</label>
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Prompts/fields/Description.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const Description = ({
}

return (
<div className="border-border-medium rounded-xl border">
<div className="border-border-inset-medium rounded-xl border">
<label
htmlFor={descriptionId}
className="text-text-secondary block px-4 pt-2 text-sm md:hidden"
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Prompts/forms/CreatePromptForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ const CreatePromptForm = ({
/>
</div>
</div>
<PromptVariables promptText={promptText} />
<PromptVariables promptText={promptText} inset />
<Description
onValueChange={(value) => methods.setValue('oneliner', value)}
tabIndex={0}
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Prompts/forms/PromptForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@ const PromptForm = ({ promptId: promptIdProp }: { promptId?: string }) => {
isEditing={isEditing}
setIsEditing={(value) => canEdit && setIsEditing(value)}
/>
<PromptVariables promptText={promptText} />
<PromptVariables promptText={promptText} inset />
<Description
initialValue={group.oneliner ?? ''}
onValueChange={canEdit ? handleUpdateOneliner : undefined}
Expand Down
23 changes: 23 additions & 0 deletions client/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1288,6 +1288,29 @@ html[data-drawer-nav] body {
border-bottom-right-radius: 0.375rem;
}

/* The column rules take the inset share of the border-medium edge, so a theme that draws no inset
* border keeps the row rules and the outer frame only. Every side edge takes the inset share and
* the frame, the first cell's start edge and the last cell's end edge, is put back whole in
* logical terms, so a table laid out right-to-left, even under a left-to-right page, keeps the
* edge that is visually outermost with no selector on direction. The selectors outrank the cell
* rules above, which restate the edge in one declaration, and leave high contrast to its own. */
html:not(.high-contrast) .markdown :is(th, td) {
border-left-color: rgb(
var(--border-medium) / calc(var(--border-medium-alpha, 1) * var(--theme-border-inset-alpha, 1))
);
border-right-color: rgb(
var(--border-medium) / calc(var(--border-medium-alpha, 1) * var(--theme-border-inset-alpha, 1))
);
}

html:not(.high-contrast) .markdown :is(th, td):first-child {
border-inline-start-color: rgb(var(--border-medium) / var(--border-medium-alpha, 1));
}

html:not(.high-contrast) .markdown :is(th, td):last-child {
border-inline-end-color: rgb(var(--border-medium) / var(--border-medium-alpha, 1));
}

.markdown a {
text-decoration-line: underline;
text-underline-offset: 2px;
Expand Down
189 changes: 189 additions & 0 deletions e2e/specs/mock/scenarios/prompt-surfaces.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
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 prompt-editor and table surfaces of the ClickHouse border switch. A `destructive` Button is
* solid by default and a 10% tint under the destructive ink when the theme's `destructiveStyle` is
* `soft`; `border-inset-medium` is `border-medium` at the inset share, so the prompt editor's form
* boxes keep their edge in the bundled themes and drop it in ClickHouse; and a markdown table keeps
* its column rules only while the inset share is above zero, and always its outer left edge, whichever way the table is laid out, including a right-to-left
* table under a left-to-right page. The probes carry the classes the
* components compose, so only the roles style them.
*/

type Mode = 'light' | 'dark';
type Paint = {
destructive: string;
inset: string;
column: string;
frame: string;
rtlColumn: string;
rtlFrame: string;
rtlInner: string;
};

const SOFT_THEME = {
version: 1,
name: 'e2e-prompt-surfaces',
modes: {
light: {
colors: { 'rgb-border-medium': '10 20 30', 'rgb-surface-destructive': '200 0 0' },
appearance: { destructiveStyle: 'soft', insetBorderAlpha: '0.5' },
},
dark: {
colors: { 'rgb-border-medium': '10 20 30', 'rgb-surface-destructive': '200 0 0' },
appearance: { destructiveStyle: 'soft', insetBorderAlpha: '0.5' },
},
},
} as const;

async function openChat(page: Page, mode: Mode, definition?: { name: string }) {
await page.addInitScript(
([appearance, stored]) => {
localStorage.setItem('color-theme', appearance 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');
}
},
[mode, definition ?? null] as [string, unknown],
);
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 (definition) {
await expect(root).toHaveAttribute('data-theme', definition.name);
} else {
await expect(root).not.toHaveAttribute('data-theme');
}
await expect(root).toHaveClass(mode === 'dark' ? /\bdark\b/ : /\blight\b/);
}

function paint(page: Page): Promise<Paint> {
return page.evaluate(() => {
const read = (html: string, pick: (node: Element) => string, className = '') => {
const host = document.createElement('div');
host.className = className;
host.innerHTML = html;
document.body.append(host);
const value = pick(host.firstElementChild as Element);
host.remove();
return value;
};
const rtl = (html: string, pick: (node: Element) => string) => {
const host = document.createElement('div');
host.className = 'markdown';
host.dir = 'rtl';
host.innerHTML = html;
document.body.append(host);
const value = pick(host.firstElementChild as Element);
host.remove();
return value;
};
return {
destructive: read(
'<div class="bg-surface-destructive theme-destructive-soft:bg-surface-destructive/10"></div>',
(node) => getComputedStyle(node).backgroundColor,
),
inset: read(
'<div class="border border-solid border-border-inset-medium"></div>',
(node) => getComputedStyle(node).borderTopColor,
),
column: read(
'<table><tbody><tr><td>one</td><td>two</td></tr></tbody></table>',
(node) => getComputedStyle(node.querySelectorAll('td')[1]).borderLeftColor,
'markdown',
),
frame: read(
'<table><tbody><tr><td>one</td><td>two</td></tr></tbody></table>',
(node) => getComputedStyle(node.querySelector('td') as Element).borderLeftColor,
'markdown',
),
rtlColumn: rtl(
'<table><tbody><tr><td>one</td><td>two</td></tr></tbody></table>',
(node) => getComputedStyle(node.querySelector('td') as Element).borderLeftColor,
),
rtlFrame: rtl(
'<table><tbody><tr><td>one</td><td>two</td></tr></tbody></table>',
(node) => getComputedStyle(node.querySelectorAll('td')[1]).borderLeftColor,
),
rtlInner: rtl(
'<table><tbody><tr><td>one</td><td>two</td></tr></tbody></table>',
(node) => getComputedStyle(node.querySelectorAll('td')[1]).borderRightColor,
),
};
});
}

/** An opaque color computes to `rgb(...)`; a tint or a drawn-away color carries an alpha slot. */
const opaque = /^rgb\(\d+, \d+, \d+\)$/;
const clear = /(\/ 0\)|, 0\)|\/ 0\.0*\))$/;
const tinted = /(\/ 0\.1\d*\)|, 0\.1\d*\))$/;

/** Each tag is written out whole: the runner finds a scenario by its literal tag. */
const CASES: Array<{
title: string;
mode: Mode;
definition?: { name: string };
expects: { [K in Exclude<keyof Paint, 'frame' | 'rtlColumn' | 'rtlFrame' | 'rtlInner'>]: RegExp };
}> = [
{
title:
'the default light theme keeps a solid destructive button, form box edge and column rule @scenario:prompt-surfaces-default-light-unchanged',
mode: 'light',
expects: { destructive: opaque, inset: opaque, column: opaque },
},
{
title:
'the default dark theme keeps a solid destructive button, form box edge and column rule @scenario:prompt-surfaces-default-dark-unchanged',
mode: 'dark',
expects: { destructive: opaque, inset: opaque, column: opaque },
},
{
title:
'the ClickHouse light theme tints the destructive button and draws no form box edge or column rule @scenario:prompt-surfaces-clickhouse-light',
mode: 'light',
definition: clickHouseTheme,
expects: { destructive: tinted, inset: clear, column: clear },
},
{
title:
'the ClickHouse dark theme tints the destructive button and draws no form box edge or column rule @scenario:prompt-surfaces-clickhouse-dark',
mode: 'dark',
definition: clickHouseTheme,
expects: { destructive: tinted, inset: clear, column: clear },
},
{
title:
'a theme that names the destructive style and inset share follows both @scenario:prompt-surfaces-follow-reference-theme',
mode: 'light',
definition: SOFT_THEME,
expects: { destructive: tinted, inset: /0\.5\)$/, column: /0\.5\)$/ },
},
];

test.describe('prompt editor and table surfaces', () => {
for (const { title, mode, definition, expects } of CASES) {
test(title, async ({ page }) => {
await openChat(page, mode, definition);

const painted = await paint(page);
expect(painted.destructive).toMatch(expects.destructive);
expect(painted.inset).toMatch(expects.inset);
expect(painted.column).toMatch(expects.column);
expect(painted.frame).toMatch(opaque);
expect(painted.rtlColumn).toMatch(expects.column);
expect(painted.rtlFrame).toMatch(opaque);
expect(painted.rtlInner).toMatch(expects.column);
});
}
});
16 changes: 16 additions & 0 deletions packages/client/src/components/Button.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ jest.mock('react-i18next', () => ({
}));

describe('Button', () => {
it('paints a destructive button solid and tints it only when the theme asks', () => {
const destructive = cn(buttonVariants({ variant: 'destructive' }));

expect(destructive).toContain('bg-surface-destructive');
expect(destructive).toContain('text-text-on-status');
expect(destructive).toContain('theme-destructive-soft:bg-surface-destructive/10');
expect(destructive).toContain('theme-destructive-soft:text-text-destructive');
// The ink stays at 4.5:1 over the light-theme tint only below a 20% hover and pressed share.
expect(destructive).toContain('theme-destructive-soft:hover:bg-surface-destructive/14');
expect(destructive).toContain('theme-destructive-soft:hover:active:bg-surface-destructive/17');
});

it('outlines a toggle in the control border', () => {
expect(cn(buttonVariants({ variant: 'outline-toggle' }))).toContain('border-border-control');
});

it('outlines an icon button in the chrome border and a text button in the light one', () => {
const icon = cn(buttonVariants({ variant: 'outline', size: 'icon-theme' }));
const text = cn(buttonVariants({ variant: 'outline', size: 'dense' }));
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,12 @@ const buttonVariantRecipe = cva(
default:
'bg-button-primary text-text-inverted hover:bg-button-primary-hover hover:active:bg-surface-inverted-pressed',
destructive:
'bg-surface-destructive text-text-on-status hover:bg-surface-destructive-hover',
'bg-surface-destructive text-text-on-status hover:bg-surface-destructive-hover theme-destructive-soft:bg-surface-destructive/10 theme-destructive-soft:text-text-destructive theme-destructive-soft:hover:bg-surface-destructive/14 theme-destructive-soft:hover:active:bg-surface-destructive/17',
outline:
'text-text-primary border border-border-light bg-transparent hover:bg-surface-hover hover:active:bg-surface-pressed hover:text-text-primary',
/** An outlined filter whose pressed state stays visible between activations. */
'outline-toggle':
'text-text-primary border border-border-light bg-transparent transition-none hover:bg-surface-hover hover:active:bg-surface-pressed hover:text-text-primary aria-pressed:border-border-heavy aria-pressed:bg-surface-active-alt aria-pressed:hover:bg-surface-active-alt',
'text-text-primary border border-border-control bg-transparent transition-none hover:bg-surface-hover hover:active:bg-surface-pressed hover:text-text-primary aria-pressed:border-border-heavy aria-pressed:bg-surface-active-alt aria-pressed:hover:bg-surface-active-alt',
/**
* A selectable answer inside a question card. `outline` is wrong here:
* its `border-light` edge measures ~1.2:1 against the panel these sit
Expand Down
6 changes: 6 additions & 0 deletions packages/client/src/theme/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,12 @@ Each status family has a foreground, a `-subtle` background, a `-border`, and a
and Dialog scrims: `surface-overlay` at the `scrimOpacity`,
`alertScrimOpacity` and `modalScrimOpacity` appearance roles (80%, 90% and
65% by default). A bundled scrim dims the page and never lifts it.
- `theme-destructive-soft:` - A variant for a `destructive` Button's tint: the
button paints a 10% tint of the destructive surface under the destructive ink
when the theme's `destructiveStyle` is `soft`. The default `fill` keeps the
solid destructive surface.
- `border-border-inset-medium` - `border-medium` at the `insetBorderAlpha`
appearance role, for the box edges of a form that sits on a stroked page.
- `ring-focus-subtle` / `outline-focus-subtle` - The keyboard ring of a row or
control inside content (tool rows, attachments, summaries, message
navigation). Defaults to `border-heavy`, so a theme that names neither keeps
Expand Down
1 change: 1 addition & 0 deletions packages/client/src/theme/defaults.css
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ html {
--theme-motion-normal: 200ms;
--theme-border-chrome-alpha: 1;
--theme-border-inset-alpha: 1;
--theme-destructive-style: fill;
--switch-unchecked: 148 148 148;
--switch-thumb: 255 255 255;
/* Follows the secondary text it was before it had a role, in every mode. */
Expand Down
Loading
Loading