diff --git a/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx b/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx
index 33390e4c7ff..126fad72e01 100644
--- a/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx
+++ b/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx
@@ -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')}
diff --git a/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx b/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx
index eebab23b98b..5f05e6a0d57 100644
--- a/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx
+++ b/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx
@@ -112,7 +112,7 @@ const RevokeKeysButton = ({
setOpen(true)}
disabled={disabled}
diff --git a/client/src/components/MCP/CustomUserVarsSection.tsx b/client/src/components/MCP/CustomUserVarsSection.tsx
index f611c0a0a26..827f88c2d25 100644
--- a/client/src/components/MCP/CustomUserVarsSection.tsx
+++ b/client/src/components/MCP/CustomUserVarsSection.tsx
@@ -174,7 +174,7 @@ export default function CustomUserVarsSection({
diff --git a/client/src/components/MCP/ServerInitializationSection.tsx b/client/src/components/MCP/ServerInitializationSection.tsx
index 5adf98b3069..f050751ee1f 100644
--- a/client/src/components/MCP/ServerInitializationSection.tsx
+++ b/client/src/components/MCP/ServerInitializationSection.tsx
@@ -132,7 +132,7 @@ export default function ServerInitializationSection({
{shouldShowRevoke && (
revokeOAuthForServer?.(serverName)}
aria-label={localize('com_ui_revoke')}
>
diff --git a/client/src/components/Messages/ScrollToBottom.tsx b/client/src/components/Messages/ScrollToBottom.tsx
index 5c16ae865d3..4ccd3d2f15f 100644
--- a/client/src/components/Messages/ScrollToBottom.tsx
+++ b/client/src/components/Messages/ScrollToBottom.tsx
@@ -47,14 +47,14 @@ const ScrollToBottom = forwardRef(
>
setDialogOpen(true)}
disabled={disabled}
>
diff --git a/client/src/components/Nav/SettingsTabs/Account/DisableTwoFactorToggle.tsx b/client/src/components/Nav/SettingsTabs/Account/DisableTwoFactorToggle.tsx
index 1de57b3a741..74a6a0bacc2 100644
--- a/client/src/components/Nav/SettingsTabs/Account/DisableTwoFactorToggle.tsx
+++ b/client/src/components/Nav/SettingsTabs/Account/DisableTwoFactorToggle.tsx
@@ -23,7 +23,7 @@ export const DisableTwoFactorToggle: React.FC = ({
const actionButton = (
+
- {apiKey.name}
+ {apiKey.name}
{status?.state === 'expired' && (
-
+
{localize('com_ui_api_key_expired')}
)}
{status?.state === 'expiring' && (
-
+
{status.days === 1
? localize('com_ui_api_key_expires_in_day')
: localize('com_ui_api_key_expires_in_days', { 0: status.days })}
)}
-
+
{apiKey.keyPrefix}…
·
@@ -57,7 +57,7 @@ export default function Item({ apiKey }: ItemProps) {
setDeleteOpen(true)}
aria-label={localize('com_ui_api_key_delete_name', { 0: apiKey.name })}
diff --git a/client/src/components/Nav/SettingsTabs/Data/ClearChats.tsx b/client/src/components/Nav/SettingsTabs/Data/ClearChats.tsx
index ed1cec0f44e..e5ba6414cbb 100644
--- a/client/src/components/Nav/SettingsTabs/Data/ClearChats.tsx
+++ b/client/src/components/Nav/SettingsTabs/Data/ClearChats.tsx
@@ -36,7 +36,7 @@ export const ClearChats = () => {
setOpen(true)}
>
{localize('com_ui_delete')}
diff --git a/client/src/components/Nav/SettingsTabs/Data/DeleteCache.tsx b/client/src/components/Nav/SettingsTabs/Data/DeleteCache.tsx
index ac6911517e3..7e5170d7e62 100644
--- a/client/src/components/Nav/SettingsTabs/Data/DeleteCache.tsx
+++ b/client/src/components/Nav/SettingsTabs/Data/DeleteCache.tsx
@@ -47,7 +47,7 @@ export const DeleteCache = ({ disabled = false }: { disabled?: boolean }) => {
setOpen(true)}
disabled={disabled || isCacheEmpty}
aria-labelledby="delete-cache-label"
diff --git a/client/src/components/Nav/SettingsTabs/Data/RevokeKeys.tsx b/client/src/components/Nav/SettingsTabs/Data/RevokeKeys.tsx
index f3087a369b8..39640854507 100644
--- a/client/src/components/Nav/SettingsTabs/Data/RevokeKeys.tsx
+++ b/client/src/components/Nav/SettingsTabs/Data/RevokeKeys.tsx
@@ -42,7 +42,7 @@ export const RevokeKeys = ({
setOpen(true)}
disabled={disabled}
aria-labelledby="revoke-info-label"
diff --git a/client/src/components/Prompts/dialogs/DeletePrompt.tsx b/client/src/components/Prompts/dialogs/DeletePrompt.tsx
index dcc6a42ccee..90333fab11d 100644
--- a/client/src/components/Prompts/dialogs/DeletePrompt.tsx
+++ b/client/src/components/Prompts/dialogs/DeletePrompt.tsx
@@ -29,7 +29,7 @@ const DeleteConfirmDialog = ({
side="bottom"
render={
{
+ 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 {
+ 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)');
+ });
+});
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index f8d8b91e99d..9535f43afb1 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -987,7 +987,7 @@
},
"client/src/components/Messages/ScrollToBottom.tsx": {
"shadcn/no-restyle": {
- "count": 3
+ "count": 1
}
},
"client/src/components/Nav/AccountSettings.tsx": {
diff --git a/packages/client/src/components/Button.spec.tsx b/packages/client/src/components/Button.spec.tsx
index 9c83ec73652..044e2f388ba 100644
--- a/packages/client/src/components/Button.spec.tsx
+++ b/packages/client/src/components/Button.spec.tsx
@@ -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');
@@ -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');
});
diff --git a/packages/client/src/components/Button.tsx b/packages/client/src/components/Button.tsx
index d7abfee4bdc..51ab33fc10e 100644
--- a/packages/client/src/components/Button.tsx
+++ b/packages/client/src/components/Button.tsx
@@ -15,9 +15,11 @@ type ButtonVariantOptions =
| 'submit'
| 'outline'
| 'outline-toggle'
+ | 'floating'
| 'choice'
| 'subtle'
| 'destructive'
+ | 'destructive-soft'
| 'secondary'
| 'ghost'
| 'quiet'
@@ -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',
+ /**
+ * 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',
diff --git a/packages/client/src/theme/README.md b/packages/client/src/theme/README.md
index 77bc0e6e562..2c23ec6defb 100644
--- a/packages/client/src/theme/README.md
+++ b/packages/client/src/theme/README.md
@@ -441,10 +441,13 @@ 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
+- `theme-destructive-soft:` - A variant for a `destructive-soft` Button's tint (the inline delete or revoke; a dialog's confirming `destructive` button stays solid): 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.
+- `theme-chrome-quiet:` - A variant for a surface that stands in for a chrome
+ outline: it applies when the theme's `chromeBorderAlpha` is `0`, so a floating
+ control or a bar over a scrolling thread stays separable once its edge is gone.
- `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
diff --git a/packages/client/src/theme/tokens.css b/packages/client/src/theme/tokens.css
index 1356e189226..0b4a7405ff5 100644
--- a/packages/client/src/theme/tokens.css
+++ b/packages/client/src/theme/tokens.css
@@ -287,6 +287,18 @@
}
}
+/* A theme whose `chromeBorderAlpha` is `0` draws no outline on chrome controls, so a control that
+ * floats over content or a bar that sits over a scrolling thread needs a surface in place of the
+ * edge. Read, like the disabled style, from the `--theme-border-chrome-alpha` property the nearest
+ * themed root carries. */
+@custom-variant theme-chrome-quiet {
+ @container style(--theme-border-chrome-alpha: 0) {
+ &:is(:root *) {
+ @slot;
+ }
+ }
+}
+
/* A theme whose `fieldFocusStyle` is `border` swaps a focused field's edge color instead of
* drawing a ring. Read, like the disabled style, from the `--theme-field-focus-style` property
* the nearest themed root carries. */
diff --git a/packages/client/src/theme/utils/applyTheme.spec.ts b/packages/client/src/theme/utils/applyTheme.spec.ts
index 93c95f9dfc3..3b60f49446b 100644
--- a/packages/client/src/theme/utils/applyTheme.spec.ts
+++ b/packages/client/src/theme/utils/applyTheme.spec.ts
@@ -148,6 +148,34 @@ describe('applyTheme', () => {
expect(root.style.getPropertyValue('--theme-motion-fast')).toBe('80ms');
});
+ it.each(['0', '.0', '0.0', '0.00'])('writes a chrome border alpha of %s as 0', (alpha) => {
+ const theme: ThemeDefinition = {
+ version: 1,
+ name: 'quiet-reference',
+ modes: { light: { appearance: { chromeBorderAlpha: alpha } } },
+ };
+
+ applyResolvedTheme(resolveTheme(theme, 'light'));
+
+ expect(document.documentElement.style.getPropertyValue('--theme-border-chrome-alpha')).toBe(
+ '0',
+ );
+ });
+
+ it('leaves a non-zero chrome border alpha as written', () => {
+ const theme: ThemeDefinition = {
+ version: 1,
+ name: 'faint-reference',
+ modes: { light: { appearance: { chromeBorderAlpha: '0.5' } } },
+ };
+
+ applyResolvedTheme(resolveTheme(theme, 'light'));
+
+ expect(document.documentElement.style.getPropertyValue('--theme-border-chrome-alpha')).toBe(
+ '0.5',
+ );
+ });
+
/** The plain `rounded-*`, `font-sans` and `font-mono` utilities read these properties, so a
* theme reaches every call site only if the adapter writes them and a reset removes them. */
it('applies and clears the radius and shadow scales and the mono family', () => {
diff --git a/packages/client/src/theme/utils/applyTheme.ts b/packages/client/src/theme/utils/applyTheme.ts
index 527529ccf2f..b010c818f0e 100644
--- a/packages/client/src/theme/utils/applyTheme.ts
+++ b/packages/client/src/theme/utils/applyTheme.ts
@@ -173,10 +173,15 @@ function mapColors(colors: IThemeRGB, base?: IThemeRGB): Array<[string, string]>
return variables;
}
+/** The `theme-chrome-quiet:` variant matches the literal `0`, so every spelling of zero is written as it. */
+function canonicalAppearance(key: keyof IThemeAppearance, value: string): string {
+ return key === 'chromeBorderAlpha' && Number(value) === 0 ? '0' : value;
+}
+
function mapAppearance(appearance: IThemeAppearance): Array<[string, string]> {
return Object.entries(themeAppearanceProperties).map(([key, property]) => [
property,
- appearance[key as keyof IThemeAppearance],
+ canonicalAppearance(key as keyof IThemeAppearance, appearance[key as keyof IThemeAppearance]),
]);
}