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
2 changes: 1 addition & 1 deletion client/src/components/Chat/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ function Header({
return (
/* The composer review is in a z-10 stacking context. Keep header controls
above it when a tall review reaches the top of a short viewport. */
<div className="from-surface-canvas via-surface-canvas/70 text-text-primary md:from-surface-canvas/80 md:via-surface-canvas/50 2xl:from-surface-canvas/0 absolute top-0 z-20 flex h-[3.25rem] w-full items-center gap-2 bg-gradient-to-b to-transparent p-2 font-semibold 2xl:via-transparent">
<div className="from-surface-canvas via-surface-canvas/70 text-text-primary md:from-surface-canvas/80 md:via-surface-canvas/50 2xl:from-surface-canvas/0 theme-chrome-quiet:bg-surface-canvas theme-chrome-quiet:bg-none absolute top-0 z-20 flex h-[3.25rem] w-full items-center gap-2 bg-gradient-to-b to-transparent p-2 font-semibold 2xl:via-transparent">
<div className={cn('flex-shrink-0 items-center', isSmallScreen ? 'flex' : 'hidden')}>
<OpenSidebar testId="header-open-sidebar-button" />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ export default function SharedLinkButton({
ref={deleteButtonRef}
type="button"
onClick={() => setShowDeleteDialog(true)}
variant="destructive"
variant="destructive-soft"
size="icon"
className="size-9 sm:size-10"
aria-label={localize('com_ui_delete_link')}
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ const RevokeKeysButton = ({
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>
<Button
variant="destructive"
variant="destructive-soft"
className="flex items-center justify-center duration-200"
onClick={() => setOpen(true)}
disabled={disabled}
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/MCP/CustomUserVarsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export default function CustomUserVarsSection({
<div className="flex justify-end gap-2">
<Button
type="button"
variant="destructive"
variant="destructive-soft"
disabled={isSubmitting}
onClick={handleRevokeClick}
>
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/MCP/ServerInitializationSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export default function ServerInitializationSection({
{shouldShowRevoke && (
<Button
size="sm"
variant="destructive"
variant="destructive-soft"
onClick={() => revokeOAuthForServer?.(serverName)}
aria-label={localize('com_ui_revoke')}
>
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/Messages/ScrollToBottom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,14 @@ const ScrollToBottom = forwardRef<HTMLDivElement, Props>(
>
<Button
type="button"
variant="outline"
variant="floating"
size="icon-theme"
shape="round"
onClick={scrollHandler}
disabled={!interactive}
aria-label={localize('com_ui_scroll_to_bottom')}
className={cn(
'me-2 bg-surface-chat/90 text-text-primary active:scale-[0.96] motion-reduce:active:scale-100',
'me-2 active:scale-[0.96] motion-reduce:active:scale-100',
/* The wrapper owns the fade, so being briefly unreachable must not
dim the control on its way in on top of it. */
'disabled:opacity-100',
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Nav/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref<HTMLDivEleme
*
* No edge on focus, by request. What marks the focused field is its caret and
* the chord appearing in the trailing slot. */
className="group bg-surface-search text-text-primary flex h-9 min-w-0 flex-1 cursor-text items-center gap-2 rounded-lg pr-1 pl-2.5"
className="group bg-surface-search text-text-primary theme-field-fill:border theme-field-fill:border-border-control flex h-9 min-w-0 flex-1 cursor-text items-center gap-2 rounded-lg pr-1 pl-2.5"
>
<Search aria-hidden="true" className="text-text-secondary size-4 shrink-0" />
<input
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
<OGDialogTrigger asChild>
<Button
aria-labelledby="delete-account-label"
variant="destructive"
variant="destructive-soft"
onClick={() => setDialogOpen(true)}
disabled={disabled}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const DisableTwoFactorToggle: React.FC<DisableTwoFactorToggleProps> = ({
const actionButton = (
<Button
ref={buttonRef}
variant={enabled ? 'destructive' : 'outline'}
variant={enabled ? 'destructive-soft' : 'outline'}
onClick={isDisableBlockedByPolicy ? undefined : onChange}
disabled={disabled}
aria-disabled={isDisableBlockedByPolicy || disabled || undefined}
Expand Down
12 changes: 6 additions & 6 deletions client/src/components/Nav/SettingsTabs/ApiKeys/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,24 @@ export default function Item({ apiKey }: ItemProps) {
const status = getExpiryStatus(apiKey.expiresAt);

return (
<li className="group flex items-start justify-between gap-3 px-4 py-3 transition-colors first:rounded-t-xl last:rounded-b-xl hover:bg-surface-secondary/50">
<li className="group hover:bg-surface-secondary/50 flex items-start justify-between gap-3 px-4 py-3 transition-colors first:rounded-t-xl last:rounded-b-xl">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-text-primary">{apiKey.name}</span>
<span className="text-text-primary truncate font-medium">{apiKey.name}</span>
{status?.state === 'expired' && (
<span className="shrink-0 rounded-full border border-status-error-border bg-status-error-subtle px-2 py-0.5 text-xs font-medium text-status-error">
<span className="border-status-error-border bg-status-error-subtle text-status-error shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium">
{localize('com_ui_api_key_expired')}
</span>
)}
{status?.state === 'expiring' && (
<span className="shrink-0 rounded-full border border-status-warning-border bg-status-warning-subtle px-2 py-0.5 text-xs font-medium text-status-warning">
<span className="border-status-warning-border bg-status-warning-subtle text-status-warning shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium">
{status.days === 1
? localize('com_ui_api_key_expires_in_day')
: localize('com_ui_api_key_expires_in_days', { 0: status.days })}
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-text-secondary">
<div className="text-text-secondary flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs">
<code className="font-mono">{apiKey.keyPrefix}…</code>
<span aria-hidden="true" className="text-text-tertiary">
·
Expand All @@ -57,7 +57,7 @@ export default function Item({ apiKey }: ItemProps) {
</div>
<Button
ref={deleteButtonRef}
variant="destructive"
variant="destructive-soft"
size="icon"
onClick={() => setDeleteOpen(true)}
aria-label={localize('com_ui_api_key_delete_name', { 0: apiKey.name })}
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Nav/SettingsTabs/Data/ClearChats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const ClearChats = () => {
<OGDialogTrigger asChild>
<Button
aria-labelledby="clear-all-chats-label"
variant="destructive"
variant="destructive-soft"
onClick={() => setOpen(true)}
>
{localize('com_ui_delete')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const DeleteCache = ({ disabled = false }: { disabled?: boolean }) => {
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>
<Button
variant="destructive"
variant="destructive-soft"
onClick={() => setOpen(true)}
disabled={disabled || isCacheEmpty}
aria-labelledby="delete-cache-label"
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Nav/SettingsTabs/Data/RevokeKeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const RevokeKeys = ({
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>
<Button
variant="destructive"
variant="destructive-soft"
onClick={() => setOpen(true)}
disabled={disabled}
aria-labelledby="revoke-info-label"
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Prompts/dialogs/DeletePrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const DeleteConfirmDialog = ({
side="bottom"
render={
<Button
variant="destructive"
variant="destructive-soft"
size="icon"
aria-label={localize('com_ui_delete')}
disabled={disabled}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export default function ActionEditor({
<OGDialogTrigger asChild>
<Button
type="button"
variant="destructive"
variant="destructive-soft"
size="icon"
disabled={isEphemeralAgent(agentId) || !action.action_id}
aria-label={localize('com_ui_delete_action')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ export default function MCPServerDialog({
type="text"
readOnly
value={redirectUri}
className="flex-1 text-text-secondary"
className="text-text-secondary flex-1"
/>
<Button
size="icon"
Expand Down Expand Up @@ -214,7 +214,7 @@ export default function MCPServerDialog({
isEditMode ? (
<div className="flex items-center gap-2">
<Button
variant="destructive"
variant="destructive-soft"
size="sm"
aria-label={localize('com_ui_delete_mcp_server_name', {
0: server?.config?.title || server?.serverName || '',
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Skills/dialogs/DeleteSkill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ function DeleteSkill({ skillId, skillName, disabled, onDelete }: DeleteSkillProp
side="bottom"
render={
<Button
variant="destructive"
variant="destructive-soft"
size="icon"
className="size-9"
aria-label={localize('com_ui_delete')}
Expand Down
107 changes: 107 additions & 0 deletions e2e/specs/mock/scenarios/chrome-quiet-surfaces.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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';

/**
* Surfaces that stand in for a chrome outline. A theme whose `chromeBorderAlpha` is 0 draws no edge
* on chrome controls, so the chat header stops fading into the thread and takes the canvas. The
* bundled default theme keeps the gradient header.
*/

type Mode = 'light' | 'dark';
type Surfaces = { headerImage: string; headerColor: string };

const QUIET_ZERO_SPELLING = {
version: 1,
name: 'e2e-quiet-zero',
modes: {
light: { colors: {}, appearance: { chromeBorderAlpha: '0.0' } },
dark: { colors: {}, appearance: { chromeBorderAlpha: '0.0' } },
},
} 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.setViewportSize({ width: 1440, height: 900 });
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/);
}

async function surfaces(page: Page): Promise<Surfaces> {
const header = page.locator('div[class~="theme-chrome-quiet:bg-none"]').first();
await expect(header).toBeVisible();
const [headerImage, headerColor] = await header.evaluate((node) => {
const style = getComputedStyle(node);
return [style.backgroundImage, style.backgroundColor];
});
return { headerImage, headerColor };
}

test.describe('surfaces that replace a chrome outline', () => {
test('the default light theme keeps the gradient header @scenario:chrome-quiet-default-light-unchanged', async ({
page,
}) => {
await openChat(page, 'light');
const result = await surfaces(page);
expect(result.headerImage).toContain('linear-gradient');
});

test('the default dark theme keeps the gradient header @scenario:chrome-quiet-default-dark-unchanged', async ({
page,
}) => {
await openChat(page, 'dark');
const result = await surfaces(page);
expect(result.headerImage).toContain('linear-gradient');
});

test('the ClickHouse theme paints an opaque header @scenario:chrome-quiet-clickhouse-light', async ({
page,
}) => {
await openChat(page, 'light', clickHouseTheme);
const result = await surfaces(page);
expect(result.headerImage).toBe('none');
expect(result.headerColor).not.toBe('rgba(0, 0, 0, 0)');
});

test('the ClickHouse dark theme paints an opaque header @scenario:chrome-quiet-clickhouse-dark', async ({
page,
}) => {
await openChat(page, 'dark', clickHouseTheme);
const result = await surfaces(page);
expect(result.headerImage).toBe('none');
expect(result.headerColor).not.toBe('rgba(0, 0, 0, 0)');
});

test('a theme that spells the zero chrome alpha as 0.0 still paints the opaque header @scenario:chrome-quiet-zero-spelling', async ({
page,
}) => {
await openChat(page, 'light', QUIET_ZERO_SPELLING);
const result = await surfaces(page);
expect(result.headerImage).toBe('none');
expect(result.headerColor).not.toBe('rgba(0, 0, 0, 0)');
});
});
2 changes: 1 addition & 1 deletion eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -987,7 +987,7 @@
},
"client/src/components/Messages/ScrollToBottom.tsx": {
"shadcn/no-restyle": {
"count": 3
"count": 1
}
},
"client/src/components/Nav/AccountSettings.tsx": {
Expand Down
20 changes: 19 additions & 1 deletion packages/client/src/components/Button.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,17 @@ jest.mock('react-i18next', () => ({
}));

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

expect(destructive).toContain('bg-surface-destructive');
expect(destructive).toContain('text-text-on-status');
expect(destructive).not.toContain('theme-destructive-soft:');
});

it('paints an inline destructive button solid and tints it only when the theme asks', () => {
const destructive = cn(buttonVariants({ variant: 'destructive-soft' }));

expect(destructive).toContain('bg-surface-destructive');
expect(destructive).toContain('text-text-on-status');
expect(destructive).toContain('theme-destructive-soft:bg-surface-destructive/10');
Expand Down Expand Up @@ -54,6 +62,16 @@ describe('Button', () => {
expect(cn(buttonVariants({ variant: 'toolbar' }))).not.toContain('py-2');
});

it('gives a floating control a surface only when its chrome outline is gone', () => {
const floating = cn(buttonVariants({ variant: 'floating', size: 'icon-theme' }));

expect(floating).toContain('border-border-chrome');
expect(floating).toContain('bg-surface-chat/90');
expect(floating).toContain('theme-chrome-quiet:bg-surface-chat');
expect(floating).toContain('theme-chrome-quiet:shadow-md');
expect(floating).toContain('theme-chrome-quiet:hover:active:bg-surface-pressed');
});

it('outlines a toggle in the control border', () => {
expect(cn(buttonVariants({ variant: 'outline-toggle' }))).toContain('border-border-control');
});
Expand Down
16 changes: 16 additions & 0 deletions packages/client/src/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ type ButtonVariantOptions =
| 'submit'
| 'outline'
| 'outline-toggle'
| 'floating'
| 'choice'
| 'subtle'
| 'destructive'
| 'destructive-soft'
| 'secondary'
| 'ghost'
| 'quiet'
Expand Down Expand Up @@ -71,9 +73,23 @@ 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',
Comment on lines 75 to +76

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 Migrate every inline destructive caller to the soft variant

When destructiveStyle is soft (as in the ClickHouse theme), removing the theme classes from destructive makes every unconverted caller solid. The migration misses several clear inline dialog triggers, including Nav/SettingsTabs/ApiKeys/Item.tsx:60, Nav/SettingsTabs/Data/RevokeKeys.tsx:45, Nav/SettingsTabs/Data/DeleteCache.tsx:50, and Skills/dialogs/DeleteSkill.tsx:65; these now look like dominant confirmation buttons before the confirmation dialog is opened, contrary to the new inline-versus-confirm contract. Convert the remaining inline delete/revoke triggers to destructive-soft while leaving their confirmation actions as destructive.

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 a744f61: the five remaining inline triggers use destructive-soft; the callers left on destructive are dialog confirms. Covered by Button.spec and the precheck at bef3482.

Comment on lines 75 to +76

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 Keep the 2FA dialog trigger on the soft variant

When 2FA is enabled, DisableTwoFactorToggle.tsx:26 still selects destructive, although that button only opens two-factor-authentication-dialog; the actual confirmation uses destructive in DisablePhase.tsx:64-67. Removing the soft-theme classes here therefore makes the ClickHouse trigger solid and as visually dominant as its confirmation. Fresh evidence beyond the earlier review thread is this conditional variant caller, which the literal variant="destructive" migration missed; use destructive-soft for its enabled arm.

AGENTS.md reference: AGENTS.md:L144-L148

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 b753f24: the enabled arm of DisableTwoFactorToggle uses destructive-soft; a grep of destructive string literals outside the variant attribute finds no other caller. Precheck and scenarios pass at this head.

/**
* A destructive action offered inline, such as a row's delete or a revoke beside its
* label. A theme whose `destructiveStyle` is `soft` tints it; the confirming button of a
* destructive dialog stays `destructive`, the strongest action on screen.
*/
'destructive-soft':
'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',
/**
* A control floating over scrolling content, such as the scroll-to-bottom chip. A theme that
* draws no chrome outline gives it an opaque fill and a lift instead, so it never reads as a
* bare glyph over the thread.
*/
floating:
'border border-border-chrome bg-surface-chat/90 text-text-primary hover:bg-surface-hover hover:active:bg-surface-pressed theme-chrome-quiet:bg-surface-chat theme-chrome-quiet:shadow-md theme-chrome-quiet:hover:bg-surface-hover theme-chrome-quiet:hover:active:bg-surface-pressed',
/** An outlined filter whose pressed state stays visible between activations. */
'outline-toggle':
'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',
Expand Down
Loading
Loading