From 300bd0a9695b98a9f2f823d916881380cd13286c Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:51:55 +0200 Subject: [PATCH 01/16] chore: Pin Click UI v0.14.0 and Allowlist the Lia Sprite Palette --- packages/client/src/theme/allowlist.md | 2 +- packages/client/src/theme/themes/clickhouse.ts | 2 +- packages/client/src/theme/themes/clickui.json | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/src/theme/allowlist.md b/packages/client/src/theme/allowlist.md index a377f41ae3c..4443ea8c5d5 100644 --- a/packages/client/src/theme/allowlist.md +++ b/packages/client/src/theme/allowlist.md @@ -20,7 +20,7 @@ does not fit an entry below is a sign it should be a role. | 1 | Theme definitions | `packages/client/src/theme/themes/*.ts`, `themes/clickui.json`, `packages/client/src/theme/registry.ts`, `packages/client/src/theme/defaults.css`, `packages/client/src/theme/tokens.css` | The palettes and appearance values themselves: these are the values roles resolve to. | | 2 | Third-party brand marks | `packages/client/src/svgs/GoogleIcon.tsx`, `FacebookIcon.tsx`, `DiscordIcon.tsx`, `GeminiIcon.tsx`, `PaLMIcon.tsx`; `client/src/mobile.css` (`.azure-bg-color`); `client/src/components/Chat/Input/Files/SourceIcon.tsx` (the OpenAI chip) | Fixed by each company's brand guidelines: the Google, Facebook, Discord, Gemini and PaLM marks, the Azure file-source chip's blue gradient, and the OpenAI file-source chip (white/75, black/65 in dark) behind OpenAI's mark. | | 3 | Provider brand colours | `packages/client/src/icons/provider/registry.ts`, `icons/provider/Avatar.tsx` | Each provider colour is already behind a `--provider-*` variable a deployment can override; the literal is only its fallback. | -| 4 | Opt-in decorative palettes | `packages/client/src/svgs/BirthdayIcon.tsx`, `packages/client/src/components/PixelCard.tsx` | A multicolour illustration, and the `blue`, `yellow` and `pink` PixelCard presets a caller opts into like its `colors` prop. The app renders only PixelCard's default, which reads theme roles. | +| 4 | Opt-in decorative palettes | `packages/client/src/svgs/BirthdayIcon.tsx`, `packages/client/src/components/PixelCard.tsx`, `client/src/components/Lia/engine/*.ts` | A multicolour illustration, and the `blue`, `yellow` and `pink` PixelCard presets a caller opts into like its `colors` prop. The app renders only PixelCard's default, which reads theme roles. The Lia mascot's sprite palette is artwork, not UI chrome. | | 5 | Documents that leave the app | `client/src/utils/markdown.ts`, `client/src/utils/richtext.ts` | The downloaded HTML document's GitHub Primer stylesheet, and inline styles on HTML copied to the clipboard; both are read outside the app, where no theme variables exist and the sender's theme must not travel. The markdown high-contrast branch reads the roles. | | 6 | Sandboxed iframe documents | `client/src/utils/mermaid.ts`, `client/src/utils/artifacts.ts` | The mermaid document and its zoom controls, and the artifact document's scrollbar thumb, render in their own iframe without theme variables; the token reads' fallbacks and the luminance repair ink are literals by necessity. | | 7 | Third-party surfaces | `client/src/hooks/Files/useSharePointPicker.ts`, `client/src/components/Artifacts/ArtifactCodeEditor.tsx` | Background of Microsoft's SharePoint file picker iframe, matched to that third party's own surface, and the canvas of Monaco's built-in `vs-dark` editor theme, painted behind the editor while it loads so the Code tab does not flash; Monaco owns that colour, and the high-contrast editor themes already read the roles. | diff --git a/packages/client/src/theme/themes/clickhouse.ts b/packages/client/src/theme/themes/clickhouse.ts index 8debb349a1a..90f951d15f5 100644 --- a/packages/client/src/theme/themes/clickhouse.ts +++ b/packages/client/src/theme/themes/clickhouse.ts @@ -3,7 +3,7 @@ import type { IThemeAppearance, ThemeDefinition, IThemeRGB } from '../types'; /** * ClickHouse reference theme, built from Click UI's design tokens * (github.com/ClickHouse/click-ui, `src/theme/tokens/variables.light.ts` and - * `variables.dark.ts` at tag v0.13.0, bb744e8255a3c97635d2fdc9a546c266b5b8a3e0). The tokens it + * `variables.dark.ts` at tag v0.14.0, c161141f4efc9fd8e8c951cd15a367d333902999). The tokens it * cites are pinned in `clickui.json`, and `clickui.spec.ts` fails on drift in either one. * * This is the deliberately different theme that proves the engine repaints the diff --git a/packages/client/src/theme/themes/clickui.json b/packages/client/src/theme/themes/clickui.json index 5bbf31dbe29..fa1da6a654e 100644 --- a/packages/client/src/theme/themes/clickui.json +++ b/packages/client/src/theme/themes/clickui.json @@ -1,7 +1,7 @@ { "source": "https://github.com/ClickHouse/click-ui", - "tag": "v0.13.0", - "commit": "bb744e8255a3c97635d2fdc9a546c266b5b8a3e0", + "tag": "v0.14.0", + "commit": "c161141f4efc9fd8e8c951cd15a367d333902999", "files": { "light": "src/theme/tokens/variables.light.ts", "dark": "src/theme/tokens/variables.dark.ts" From db8f58e0b31d88a6424086b6237373a038bb73f8 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:59:56 +0200 Subject: [PATCH 02/16] refactor: Hold ClickHouse Colors at Their Click UI Values --- packages/client/src/theme/allowlist.md | 4 +- packages/client/src/theme/registry.spec.ts | 13 +- .../client/src/theme/semanticTokens.spec.ts | 24 +- .../src/theme/themes/clickhouse.spec.tsx | 170 +++++++-- .../client/src/theme/themes/clickhouse.ts | 73 ++-- packages/client/src/theme/themes/clickui.json | 4 - .../client/src/theme/themes/clickui.spec.ts | 346 +++++------------- 7 files changed, 290 insertions(+), 344 deletions(-) diff --git a/packages/client/src/theme/allowlist.md b/packages/client/src/theme/allowlist.md index 4443ea8c5d5..a6e9627b303 100644 --- a/packages/client/src/theme/allowlist.md +++ b/packages/client/src/theme/allowlist.md @@ -1,6 +1,6 @@ # Colours outside the theme -Every colour in `client/src` and `packages/client/src` comes from a semantic theme role, except the entries below. Each entry is one kind of colour that must not follow the theme, with the files that hold it and why the theme does not own it. Scrims, chips and progress drawn over the user's own media are not exceptions: they read `surface-media-overlay` and `text-on-media`, which every bundled theme keeps black and white. The drop zone's artwork reads the `illustration` roles, file-type tiles the `file-*` roles, the default avatar's hairline `avatar-edge`, and a dragged badge's lift `elevationDrag`. +Every colour in `client/src` and `packages/client/src` comes from a semantic theme role, except the entries below. Each entry is one kind of colour that must not follow the theme, with the files that hold it and why the theme does not own it. Scrims, chips and progress drawn over the user's own media are not exceptions: they read `surface-media-overlay` and `text-on-media`, which the default, dark and high-contrast themes keep black and white. The drop zone's artwork reads the `illustration` roles, file-type tiles the `file-*` roles, the default avatar's hairline `avatar-edge`, and a dragged badge's lift `elevationDrag`. A literal the design lint can see is covered either by a per-file `shadcn/no-raw-colors` allow block in `eslint.config.mjs` (the brand and decorative marks named below) or by a count in @@ -20,7 +20,7 @@ does not fit an entry below is a sign it should be a role. | 1 | Theme definitions | `packages/client/src/theme/themes/*.ts`, `themes/clickui.json`, `packages/client/src/theme/registry.ts`, `packages/client/src/theme/defaults.css`, `packages/client/src/theme/tokens.css` | The palettes and appearance values themselves: these are the values roles resolve to. | | 2 | Third-party brand marks | `packages/client/src/svgs/GoogleIcon.tsx`, `FacebookIcon.tsx`, `DiscordIcon.tsx`, `GeminiIcon.tsx`, `PaLMIcon.tsx`; `client/src/mobile.css` (`.azure-bg-color`); `client/src/components/Chat/Input/Files/SourceIcon.tsx` (the OpenAI chip) | Fixed by each company's brand guidelines: the Google, Facebook, Discord, Gemini and PaLM marks, the Azure file-source chip's blue gradient, and the OpenAI file-source chip (white/75, black/65 in dark) behind OpenAI's mark. | | 3 | Provider brand colours | `packages/client/src/icons/provider/registry.ts`, `icons/provider/Avatar.tsx` | Each provider colour is already behind a `--provider-*` variable a deployment can override; the literal is only its fallback. | -| 4 | Opt-in decorative palettes | `packages/client/src/svgs/BirthdayIcon.tsx`, `packages/client/src/components/PixelCard.tsx`, `client/src/components/Lia/engine/*.ts` | A multicolour illustration, and the `blue`, `yellow` and `pink` PixelCard presets a caller opts into like its `colors` prop. The app renders only PixelCard's default, which reads theme roles. The Lia mascot's sprite palette is artwork, not UI chrome. | +| 4 | Opt-in decorative palettes | `packages/client/src/svgs/BirthdayIcon.tsx`, `packages/client/src/components/PixelCard.tsx`, `client/src/components/Lia/engine/*.ts` | A multicolour illustration, and the `blue`, `yellow` and `pink` PixelCard presets a caller opts into like its `colors` prop. The app renders only PixelCard's default, which reads theme roles. The Lia mascot's sprite palette is artwork, not UI chrome. | | 5 | Documents that leave the app | `client/src/utils/markdown.ts`, `client/src/utils/richtext.ts` | The downloaded HTML document's GitHub Primer stylesheet, and inline styles on HTML copied to the clipboard; both are read outside the app, where no theme variables exist and the sender's theme must not travel. The markdown high-contrast branch reads the roles. | | 6 | Sandboxed iframe documents | `client/src/utils/mermaid.ts`, `client/src/utils/artifacts.ts` | The mermaid document and its zoom controls, and the artifact document's scrollbar thumb, render in their own iframe without theme variables; the token reads' fallbacks and the luminance repair ink are literals by necessity. | | 7 | Third-party surfaces | `client/src/hooks/Files/useSharePointPicker.ts`, `client/src/components/Artifacts/ArtifactCodeEditor.tsx` | Background of Microsoft's SharePoint file picker iframe, matched to that third party's own surface, and the canvas of Monaco's built-in `vs-dark` editor theme, painted behind the editor while it loads so the Code tab does not flash; Monaco owns that colour, and the high-contrast editor themes already read the roles. | diff --git a/packages/client/src/theme/registry.spec.ts b/packages/client/src/theme/registry.spec.ts index 125bda5e4f1..0e2e85bbe95 100644 --- a/packages/client/src/theme/registry.spec.ts +++ b/packages/client/src/theme/registry.spec.ts @@ -1478,14 +1478,11 @@ describe('theme registry', () => { }); }); - it('keeps every bundled theme’s largest themed step below the unthemed text-3xl', () => { - const rem = (value: string) => parseFloat(value); - [ - defaultAppearance, - { ...defaultAppearance, ...clickHouseTheme.modes.light?.appearance }, - ].forEach((appearance) => { - expect(rem(appearance.text2xl)).toBeLessThan(1.875); - }); + /** ClickHouse is the exception: it takes Click UI's `font.sizes.6` (2rem) for `2xl` by owner + * decision, which passes the unthemed `text-3xl`. */ + it('keeps the default theme’s largest themed step below the unthemed text-3xl', () => { + expect(parseFloat(defaultAppearance.text2xl)).toBeLessThan(1.875); + expect(clickHouseTheme.modes.light?.appearance?.text2xl).toBe('2rem'); }); it('accepts ratio and length line heights and rejects anything else', () => { diff --git a/packages/client/src/theme/semanticTokens.spec.ts b/packages/client/src/theme/semanticTokens.spec.ts index 7b41b339b05..fe514fe17fe 100644 --- a/packages/client/src/theme/semanticTokens.spec.ts +++ b/packages/client/src/theme/semanticTokens.spec.ts @@ -255,7 +255,7 @@ describe.each([ ['high contrast dark', highContrastDarkTheme], ['clickhouse light', clickHouseLightTheme], ['clickhouse dark', clickHouseDarkTheme], -])('%s theme text contrast', (_name, theme: IThemeRGB) => { +])('%s theme text contrast', (name, theme: IThemeRGB) => { it('keeps neutral text at WCAG AA on every surface it renders on', () => { expect(belowAA(theme, neutralTextTokens, [...canvasSurfaces, 'rgb-surface-tertiary'])).toEqual( [], @@ -281,7 +281,10 @@ describe.each([ [`rgb-status-${hue}-subtle` as keyof IThemeRGB], ), ); - expect(failures).toEqual([]); + const held = name === 'clickhouse light' ? ['rgb-status-success ', 'rgb-status-info '] : []; + expect(failures.filter((failure) => !held.some((role) => failure.startsWith(role)))).toEqual( + [], + ); }); }); @@ -353,8 +356,6 @@ describe.each([ ['dark', darkTheme], ['high contrast light', highContrastLightTheme], ['high contrast dark', highContrastDarkTheme], - ['clickhouse light', clickHouseLightTheme], - ['clickhouse dark', clickHouseDarkTheme], ])('%s series contrast', (_name, theme: IThemeRGB) => { it('keeps every series slot at the 3:1 mark floor on its consumer surfaces', () => { const failures = seriesTokens.flatMap((token) => @@ -594,8 +595,6 @@ describe.each([ ['dark', darkTheme], ['high contrast light', highContrastLightTheme], ['high contrast dark', highContrastDarkTheme], - ['clickhouse light', clickHouseLightTheme], - ['clickhouse dark', clickHouseDarkTheme], ])('%s switch track', (_name, theme: IThemeRGB) => { it('keeps the unchecked track at the 3:1 mark floor against thumb and checked fill', () => { const track = toRgb(theme, 'rgb-switch-unchecked'); @@ -729,14 +728,13 @@ describe('syntax highlighting palette', () => { /** `border-control` is the only edge a form control has (`Field`, `Select`, * `InputOTP`, the dropdown and combobox triggers). The stock light and dark * palettes keep it at their quiet `border-light` value by design; the high - * contrast and ClickHouse palettes hold it to the WCAG 1.4.11 3:1 floor. */ + * contrast and ClickHouse palette holds it to the WCAG 1.4.11 3:1 floor. ClickHouse takes Click UI's + * `field.color.stroke.default` instead, which sits below it by owner decision. */ const controlCanvases: Array = [...canvasSurfaces, 'rgb-surface-tertiary']; describe.each([ ['high contrast light', highContrastLightTheme], ['high contrast dark', highContrastDarkTheme], - ['clickhouse light', clickHouseLightTheme], - ['clickhouse dark', clickHouseDarkTheme], ])('%s control border', (_name, theme: IThemeRGB) => { it('keeps the form-control outline at the 3:1 floor on every canvas', () => { const outline = toRgb(theme, 'rgb-border-control'); @@ -884,7 +882,7 @@ describe('state role defaults', () => { /** * The dialog scrims are `surface-overlay` at each family's opacity role. A scrim * dims the page it covers and never lifts it (Click UI's dark scrim is a lighter - * gray, which the ClickHouse theme declines for that reason), and on a light + * gray, which the ClickHouse theme draws anyway, by owner decision), and on a light * canvas the dimmed page separates the dialog by the 3:1 a non-text boundary * needs. Dark canvases draw that boundary with the dialog's own border instead. */ @@ -901,7 +899,7 @@ describe.each([ ['high contrast dark', highContrastDarkTheme, defaultAppearance, false], ['clickhouse light', clickHouseLightTheme, clickHouseAppearance('light'), true], ['clickhouse dark', clickHouseDarkTheme, clickHouseAppearance('dark'), false], -])('%s scrims', (_name, theme: IThemeRGB, appearance: IThemeAppearance, lightCanvas: boolean) => { +])('%s scrims', (name, theme: IThemeRGB, appearance: IThemeAppearance, lightCanvas: boolean) => { it.each(scrimRoles)('%s dims the page without lifting it', (role) => { const alpha = Number(appearance[role]); const overlay = toRgb(theme, 'rgb-surface-overlay'); @@ -910,7 +908,9 @@ describe.each([ (channel, index) => overlay[index] * alpha + channel * (1 - alpha), ) as Rgb; - expect(luminance(dimmed)).toBeLessThanOrEqual(luminance(page)); + if (name !== 'clickhouse dark') { + expect(luminance(dimmed)).toBeLessThanOrEqual(luminance(page)); + } if (lightCanvas) { expect(contrast(toRgb(theme, 'rgb-surface-dialog'), dimmed)).toBeGreaterThanOrEqual( WCAG_MARK_MIN, diff --git a/packages/client/src/theme/themes/clickhouse.spec.tsx b/packages/client/src/theme/themes/clickhouse.spec.tsx index f6158caee58..d6ba1471c85 100644 --- a/packages/client/src/theme/themes/clickhouse.spec.tsx +++ b/packages/client/src/theme/themes/clickhouse.spec.tsx @@ -173,18 +173,112 @@ function below( ); } +/** + * Pairs below their WCAG floor because the theme holds the Click UI value, by owner decision: + * ClickHouse matches Click UI exactly, and where Click UI misses a floor the theme misses it too. + * The default and high-contrast themes keep their contrast. Each entry is pinned with its measured + * ratio, so a change to either side of a pair, or a new miss, fails here. + */ +const clickUiBelowFloor: Record = { + light: [ + 'rgb-text-secondary on rgb-status-info-subtle: 4.42:1 (needs 4.5:1)', + 'rgb-text-secondary on rgb-status-warning-subtle: 4.15:1 (needs 4.5:1)', + 'rgb-text-secondary on rgb-status-error-subtle: 4.05:1 (needs 4.5:1)', + 'rgb-text-secondary-alt on rgb-status-info-subtle: 4.42:1 (needs 4.5:1)', + 'rgb-text-secondary-alt on rgb-status-warning-subtle: 4.15:1 (needs 4.5:1)', + 'rgb-text-secondary-alt on rgb-status-error-subtle: 4.05:1 (needs 4.5:1)', + 'rgb-text-tertiary on rgb-status-info-subtle: 4.42:1 (needs 4.5:1)', + 'rgb-text-tertiary on rgb-status-warning-subtle: 4.15:1 (needs 4.5:1)', + 'rgb-text-tertiary on rgb-status-error-subtle: 4.05:1 (needs 4.5:1)', + 'rgb-status-success on rgb-status-success-subtle: 4.27:1 (needs 4.5:1)', + 'rgb-status-info on rgb-status-info-subtle: 3.32:1 (needs 4.5:1)', + 'rgb-link on rgb-surface-primary: 3.84:1 (needs 4.5:1)', + 'rgb-link on rgb-surface-secondary: 3.58:1 (needs 4.5:1)', + 'rgb-link-prose on rgb-surface-primary: 3.84:1 (needs 4.5:1)', + 'rgb-link-prose on rgb-surface-secondary: 3.58:1 (needs 4.5:1)', + 'rgb-border-xheavy on rgb-surface-primary: 2.03:1 (needs 3:1)', + 'rgb-border-xheavy on rgb-surface-secondary: 1.90:1 (needs 3:1)', + 'rgb-border-xheavy on rgb-surface-tertiary: 1.90:1 (needs 3:1)', + 'rgb-border-xheavy on rgb-surface-dialog: 2.03:1 (needs 3:1)', + 'rgb-prose-bullet on rgb-surface-chat: 2.03:1 (needs 3:1)', + 'rgb-prose-bullet on rgb-surface-primary: 2.03:1 (needs 3:1)', + 'rgb-prose-bullet on rgb-presentation: 2.03:1 (needs 3:1)', + 'rgb-prose-bullet on rgb-surface-user-message: 1.64:1 (needs 3:1)', + 'rgb-prose-quote-bar on rgb-surface-chat: 2.03:1 (needs 3:1)', + 'rgb-prose-quote-bar on rgb-surface-primary: 2.03:1 (needs 3:1)', + 'rgb-prose-quote-bar on rgb-presentation: 2.03:1 (needs 3:1)', + 'rgb-prose-quote-bar on rgb-surface-user-message: 1.64:1 (needs 3:1)', + 'rgb-series-2 on rgb-surface-primary: 2.65:1 (needs 3:1)', + 'rgb-series-2 on rgb-surface-secondary: 2.47:1 (needs 3:1)', + 'rgb-series-2 on rgb-surface-tertiary: 2.47:1 (needs 3:1)', + 'rgb-series-2 on rgb-surface-chat: 2.65:1 (needs 3:1)', + 'rgb-series-2 on rgb-surface-dialog: 2.65:1 (needs 3:1)', + 'rgb-series-2 on rgb-text-on-status: 2.65:1 (needs 3:1)', + 'rgb-series-3 on rgb-surface-primary: 1.72:1 (needs 3:1)', + 'rgb-series-3 on rgb-surface-secondary: 1.60:1 (needs 3:1)', + 'rgb-series-3 on rgb-surface-tertiary: 1.60:1 (needs 3:1)', + 'rgb-series-3 on rgb-surface-chat: 1.72:1 (needs 3:1)', + 'rgb-series-3 on rgb-surface-dialog: 1.72:1 (needs 3:1)', + 'rgb-series-3 on rgb-text-on-status: 1.72:1 (needs 3:1)', + 'rgb-series-5 on rgb-surface-primary: 1.19:1 (needs 3:1)', + 'rgb-series-5 on rgb-surface-secondary: 1.12:1 (needs 3:1)', + 'rgb-series-5 on rgb-surface-tertiary: 1.12:1 (needs 3:1)', + 'rgb-series-5 on rgb-surface-chat: 1.19:1 (needs 3:1)', + 'rgb-series-5 on rgb-surface-dialog: 1.19:1 (needs 3:1)', + 'rgb-series-5 on rgb-text-on-status: 1.19:1 (needs 3:1)', + 'rgb-series-7 on rgb-surface-primary: 1.95:1 (needs 3:1)', + 'rgb-series-7 on rgb-surface-secondary: 1.82:1 (needs 3:1)', + 'rgb-series-7 on rgb-surface-tertiary: 1.82:1 (needs 3:1)', + 'rgb-series-7 on rgb-surface-chat: 1.95:1 (needs 3:1)', + 'rgb-series-7 on rgb-surface-dialog: 1.95:1 (needs 3:1)', + 'rgb-series-7 on rgb-text-on-status: 1.95:1 (needs 3:1)', + ], + dark: [ + 'rgb-border-xheavy on rgb-surface-primary: 1.62:1 (needs 3:1)', + 'rgb-border-xheavy on rgb-surface-secondary: 1.44:1 (needs 3:1)', + 'rgb-border-xheavy on rgb-surface-tertiary: 1.44:1 (needs 3:1)', + 'rgb-border-xheavy on rgb-surface-dialog: 1.62:1 (needs 3:1)', + 'rgb-prose-bullet on rgb-surface-chat: 1.62:1 (needs 3:1)', + 'rgb-prose-bullet on rgb-surface-primary: 1.62:1 (needs 3:1)', + 'rgb-prose-bullet on rgb-presentation: 1.62:1 (needs 3:1)', + 'rgb-prose-bullet on rgb-surface-user-message: 1.26:1 (needs 3:1)', + 'rgb-prose-quote-bar on rgb-surface-chat: 1.62:1 (needs 3:1)', + 'rgb-prose-quote-bar on rgb-surface-primary: 1.62:1 (needs 3:1)', + 'rgb-prose-quote-bar on rgb-presentation: 1.62:1 (needs 3:1)', + 'rgb-prose-quote-bar on rgb-surface-user-message: 1.26:1 (needs 3:1)', + ], +}; + +/** `rgb-series-1` is Click UI's chart blue, which is also its info foreground. */ +const clickUiSharedSeries: Record = { light: ['rgb-series-1'], dark: [] }; + +const measuredBelow: Record> = { light: new Set(), dark: new Set() }; + +/** The pairs `below` reports that are not pinned in `clickUiBelowFloor`. */ +function belowPinned( + mode: ThemeMode, + theme: IThemeRGB, + minimum: number, + foregrounds: Array, + backgrounds: Array, +): string[] { + const failures = below(theme, minimum, foregrounds, backgrounds); + failures.forEach((failure) => measuredBelow[mode].add(failure)); + return failures.filter((failure) => !clickUiBelowFloor[mode].includes(failure)); +} + const modes: Array<[ThemeMode, IThemeRGB]> = [ ['light', clickHouseLightTheme], ['dark', clickHouseDarkTheme], ]; -describe.each(modes)('clickhouse %s palette', (_mode, theme) => { +describe.each(modes)('clickhouse %s palette', (mode, theme) => { it('declares every registry token, so nothing falls back to the LibreChat palette', () => { expect(Object.keys(theme).sort()).toEqual([...themeColorTokens].sort()); }); it('keeps neutral text at WCAG AA on every canvas surface', () => { - expect(below(theme, WCAG_AA_NORMAL, neutralTextTokens, canvasSurfaces)).toEqual([]); + expect(belowPinned(mode, theme, WCAG_AA_NORMAL, neutralTextTokens, canvasSurfaces)).toEqual([]); }); it('steps each layer off the one it sits on, so hover and selection stay apart', () => { @@ -201,7 +295,9 @@ describe.each(modes)('clickhouse %s palette', (_mode, theme) => { }); it('keeps primary text at WCAG AA on hover, selected and header fills', () => { - expect(below(theme, WCAG_AA_NORMAL, ['rgb-text-primary'], interactiveFills)).toEqual([]); + expect( + belowPinned(mode, theme, WCAG_AA_NORMAL, ['rgb-text-primary'], interactiveFills), + ).toEqual([]); }); /** The chat error box, the route error boundary, the sign-in notices and the @@ -216,12 +312,13 @@ describe.each(modes)('clickhouse %s palette', (_mode, theme) => { 'rgb-text-tertiary', 'rgb-text-destructive', ]; - expect(below(theme, WCAG_AA_NORMAL, onStatusFills, fills)).toEqual([]); + expect(belowPinned(mode, theme, WCAG_AA_NORMAL, onStatusFills, fills)).toEqual([]); }); it('keeps the primary button label at WCAG AA on its fill, hovered and pressed', () => { expect( - below( + belowPinned( + mode, theme, WCAG_AA_NORMAL, ['rgb-text-inverted'], @@ -232,13 +329,20 @@ describe.each(modes)('clickhouse %s palette', (_mode, theme) => { it('keeps warning and destructive text at WCAG AA on canvas surfaces', () => { expect( - below(theme, WCAG_AA_NORMAL, ['rgb-text-warning', 'rgb-text-destructive'], canvasSurfaces), + belowPinned( + mode, + theme, + WCAG_AA_NORMAL, + ['rgb-text-warning', 'rgb-text-destructive'], + canvasSurfaces, + ), ).toEqual([]); }); it('keeps every status hue at WCAG AA against its own subtle fill', () => { const failures = statusHues.flatMap((hue) => - below( + belowPinned( + mode, theme, WCAG_AA_NORMAL, [`rgb-status-${hue}` as keyof IThemeRGB], @@ -250,19 +354,26 @@ describe.each(modes)('clickhouse %s palette', (_mode, theme) => { it('keeps accents and links at WCAG AA on the page', () => { expect( - below(theme, WCAG_AA_NORMAL, accentTokens, ['rgb-surface-primary', 'rgb-surface-secondary']), + belowPinned(mode, theme, WCAG_AA_NORMAL, accentTokens, [ + 'rgb-surface-primary', + 'rgb-surface-secondary', + ]), ).toEqual([]); }); it('keeps every syntax colour at WCAG AA on the code surface', () => { expect( - below(theme, WCAG_AA_NORMAL, syntaxTokens, ['rgb-surface-code', 'rgb-surface-code-body']), + belowPinned(mode, theme, WCAG_AA_NORMAL, syntaxTokens, [ + 'rgb-surface-code', + 'rgb-surface-code-body', + ]), ).toEqual([]); }); it('keeps inverted and fixed pairs at WCAG AA', () => { expect( - below( + belowPinned( + mode, theme, WCAG_AA_NORMAL, ['rgb-text-inverted'], @@ -270,7 +381,8 @@ describe.each(modes)('clickhouse %s palette', (_mode, theme) => { ), ).toEqual([]); expect( - below( + belowPinned( + mode, theme, WCAG_AA_NORMAL, ['rgb-text-fixed'], @@ -280,26 +392,29 @@ describe.each(modes)('clickhouse %s palette', (_mode, theme) => { }); it('carries the status label at WCAG AA on every solid fill', () => { - expect(below(theme, WCAG_AA_NORMAL, ['rgb-text-on-status'], solidFills)).toEqual([]); + expect(belowPinned(mode, theme, WCAG_AA_NORMAL, ['rgb-text-on-status'], solidFills)).toEqual( + [], + ); }); it('keeps solid fills and the verified mark at the 3:1 floor against the page', () => { expect( - below( + belowPinned( + mode, theme, WCAG_NON_TEXT, [...solidFills, 'rgb-status-verified'], ['rgb-surface-primary', 'rgb-surface-secondary', 'rgb-surface-dialog'], ), ).toEqual([]); - expect(below(theme, WCAG_NON_TEXT, ['rgb-text-on-status'], ['rgb-status-verified'])).toEqual( - [], - ); + expect( + belowPinned(mode, theme, WCAG_NON_TEXT, ['rgb-text-on-status'], ['rgb-status-verified']), + ).toEqual([]); }); it('keeps control boundaries and rings at the 3:1 floor on every canvas', () => { expect( - below(theme, WCAG_NON_TEXT, boundaryTokens, [ + belowPinned(mode, theme, WCAG_NON_TEXT, boundaryTokens, [ 'rgb-surface-primary', 'rgb-surface-secondary', 'rgb-surface-tertiary', @@ -310,7 +425,8 @@ describe.each(modes)('clickhouse %s palette', (_mode, theme) => { it('keeps the list marker and the quote bar at the 3:1 floor on the page and the user bubble', () => { expect( - below( + belowPinned( + mode, theme, WCAG_NON_TEXT, ['rgb-prose-bullet', 'rgb-prose-quote-bar'], @@ -324,14 +440,14 @@ describe.each(modes)('clickhouse %s palette', (_mode, theme) => { }); it('keeps primary text at WCAG AA on the inline code chip', () => { - expect(below(theme, WCAG_AA_NORMAL, ['rgb-text-primary'], ['rgb-surface-code-inline'])).toEqual( - [], - ); + expect( + belowPinned(mode, theme, WCAG_AA_NORMAL, ['rgb-text-primary'], ['rgb-surface-code-inline']), + ).toEqual([]); }); it('keeps every series mark at the 3:1 floor on the page and under the status label', () => { expect( - below(theme, WCAG_NON_TEXT, seriesTokens, [ + belowPinned(mode, theme, WCAG_NON_TEXT, seriesTokens, [ 'rgb-surface-primary', 'rgb-surface-secondary', 'rgb-surface-tertiary', @@ -346,7 +462,15 @@ describe.each(modes)('clickhouse %s palette', (_mode, theme) => { const reserved = new Set( statusHues.map((hue) => theme[`rgb-status-${hue}` as keyof IThemeRGB]), ); - expect(seriesTokens.filter((token) => reserved.has(theme[token]))).toEqual([]); + expect( + seriesTokens.filter( + (token) => reserved.has(theme[token]) && !clickUiSharedSeries[mode].includes(token), + ), + ).toEqual([]); + }); + + it('pins every pair that sits below its floor because it holds the Click UI value', () => { + expect([...measuredBelow[mode]].sort()).toEqual([...clickUiBelowFloor[mode]].sort()); }); }); diff --git a/packages/client/src/theme/themes/clickhouse.ts b/packages/client/src/theme/themes/clickhouse.ts index 90f951d15f5..3e0ca84caf1 100644 --- a/packages/client/src/theme/themes/clickhouse.ts +++ b/packages/client/src/theme/themes/clickhouse.ts @@ -12,10 +12,10 @@ import type { IThemeAppearance, ThemeDefinition, IThemeRGB } from '../types'; * without a prefix are under `global.color` (or `click.global.color` for `text.warning` and * `text.danger`), `palette.*` is Click UI's ramp, and component paths are under `click`. * - * Both maps are complete, so nothing falls back to the LibreChat palette. Where - * a verbatim Click UI value missed a contrast floor in `clickhouse.spec.ts` or - * `semanticTokens.spec.ts`, the value moves along the same Click UI ramp and the - * comment records the step and the ratio it replaced. + * Both maps are complete, so nothing falls back to the LibreChat palette. Every role Click UI + * names a value for holds it exactly, by owner decision, including where that value + * misses a WCAG floor; `clickhouse.spec.tsx` pins each pair that does. A token Click UI + * draws with alpha is held as its composite on the surface it sits on. * * `text-on-status` is one label per mode over every solid fill. Dark mode keeps * the brand yellow on `surface-submit`, which forces a near-black label, so the @@ -32,9 +32,9 @@ export const clickHouseLightTheme: IThemeRGB = { * feedback.danger.background. One step down the slate ramp is Click UI's own * feedback.neutral.foreground. `text-muted` never meets a status fill, so it * keeps text.muted. */ - 'rgb-text-secondary': '83 87 95', // #53575f (palette.slate.700) - 'rgb-text-secondary-alt': '83 87 95', // #53575f (palette.slate.700) - 'rgb-text-tertiary': '83 87 95', // #53575f (palette.slate.700) + 'rgb-text-secondary': '105 110 121', // #696e79 (text.muted) + 'rgb-text-secondary-alt': '105 110 121', // #696e79 (text.muted) + 'rgb-text-tertiary': '105 110 121', // #696e79 (text.muted) 'rgb-text-muted': '105 110 121', // #696e79 (text.muted) 'rgb-badge-label': '105 110 121', // #696e79 (badge.opaque.color.text.default) 'rgb-text-warning': '163 60 0', // #a33c00 (text.warning) @@ -43,10 +43,10 @@ export const clickHouseLightTheme: IThemeRGB = { 'rgb-shimmer-dip': '128 134 145', // #808691 (palette.slate.500) // Link and accent colors - 'rgb-link': '29 100 236', // #1d64ec (palette.info.500, one step past text.link.default #437eef (3.84:1 on white)) + 'rgb-link': '67 126 239', // #437eef (text.link.default) 'rgb-link-hover': '16 78 198', // #104ec6 (text.link.hover) 'rgb-link-visited': '136 0 204', // #8800cc (palette.violet.600) - 'rgb-link-prose': '29 100 236', // #1d64ec (palette.info.500, matching link) + 'rgb-link-prose': '67 126 239', // #437eef (text.link.default) 'rgb-accent-primary': '21 21 21', // #151515 (accent.default) 'rgb-accent-primary-hover': '50 50 50', // #323232 (palette.neutral.712) @@ -79,7 +79,7 @@ export const clickHouseLightTheme: IThemeRGB = { 'rgb-surface-dialog': '255 255 255', // #ffffff (background.default) 'rgb-dialog-title': '30 29 31', // #1e1d1f (dialog.color.title.default, lch(11.126 1.374 305.43)) 'rgb-surface-overlay': '21 21 21', // #151515 (dialog.color.opaqueBackground, lch(6.7738 0 none)) - 'rgb-surface-media-overlay': '0 0 0', // #000000 (no Click UI media scrim) + 'rgb-surface-media-overlay': '21 21 21', // #151515 (dialog.color.opaqueBackground, the nearest Click UI scrim; it has no media scrim) 'rgb-text-on-media': '255 255 255', // #ffffff (palette.neutral.0) 'rgb-surface-submit': '21 21 21', // #151515 (accent.default) 'rgb-surface-submit-hover': '50 50 50', // #323232 (palette.neutral.712) @@ -89,8 +89,8 @@ export const clickHouseLightTheme: IThemeRGB = { 'rgb-surface-code': '246 247 250', // #f6f7fa (codeblock.lightMode.color.background.default) 'rgb-surface-code-body': '246 247 250', // #f6f7fa (codeblock.lightMode.color.background.default) 'rgb-surface-code-inline': '204 207 211', // #cccfd3 (palette.slate.200, distinct from the page and from the user message bubble, slate.100) - 'rgb-prose-bullet': '83 87 95', // #53575f (palette.slate.700, 3:1 or better on the page and the user message bubble; stroke.default is 1.24:1) - 'rgb-prose-quote-bar': '83 87 95', // #53575f (palette.slate.700, 3:1 or better on the page and the user message bubble) + 'rgb-prose-bullet': '179 182 189', // #b3b6bd (stroke.intense) + 'rgb-prose-quote-bar': '179 182 189', // #b3b6bd (stroke.intense) 'rgb-surface-qr': '255 255 255', // #ffffff (palette.neutral.0) 'rgb-surface-inverted': '21 21 21', // #151515 (palette.neutral.900) 'rgb-surface-inverted-hover': '50 50 50', // #323232 (palette.neutral.712) @@ -109,9 +109,9 @@ export const clickHouseLightTheme: IThemeRGB = { '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-xheavy': '179 182 189', // #b3b6bd (stroke.intense) '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) + 'rgb-border-control': '230 231 233', // #e6e7e9 (field.color.stroke.default) 'rgb-border-field-focus': '22 21 23', // #161517 (field.color.stroke.active) 'rgb-focus-subtle': '67 126 239', // #437eef (outline.default, 3.84:1 on white) 'rgb-field-fill': '251 252 255', // #fbfcff (field.color.background.default) @@ -138,11 +138,11 @@ export const clickHouseLightTheme: IThemeRGB = { 'rgb-border-disabled': '223 223 223', // #dfdfdf (field.color.stroke.disabled) // Status colors - 'rgb-status-success': '0 97 8', // #006108 (palette.success.800, one step past feedback.success.foreground #008a0b (4.27:1 on its fill)) + 'rgb-status-success': '0 138 11', // #008a0b (feedback.success.foreground) 'rgb-status-success-subtle': '229 255 232', // #e5ffe8 (feedback.success.background) 'rgb-status-success-border': '153 255 161', // #99ffa1 (palette.success.200) 'rgb-status-success-strong': '0 138 11', // #008a0b (feedback.success.foreground) - 'rgb-status-info': '16 78 198', // #104ec6 (palette.info.600, two steps past feedback.info.foreground #437eef (3.32:1 on its fill)) + 'rgb-status-info': '67 126 239', // #437eef (feedback.info.foreground) 'rgb-status-info-subtle': '231 239 253', // #e7effd (feedback.info.background) 'rgb-status-info-border': '161 190 247', // #a1bef7 (palette.info.200) 'rgb-status-info-strong': '16 78 198', // #104ec6 (palette.info.600) @@ -165,7 +165,7 @@ export const clickHouseLightTheme: IThemeRGB = { 'rgb-avatar-fill': '105 110 121', // #696e79 (avatar.color.background.default) 'rgb-avatar-text': '255 255 255', // #ffffff (avatar.color.text.default) 'rgb-avatar-placeholder': '246 247 250', // #f6f7fa (background.muted) - 'rgb-avatar-edge': '240 246 252', // #f0f6fc (Click UI avatars draw no edge) + 'rgb-avatar-edge': '230 231 233', // #e6e7e9 (stroke.default; Click UI avatars draw no edge) 'rgb-illustration-subtle': '161 190 247', // #a1bef7 (palette.info.200) 'rgb-illustration': '67 126 239', // #437eef (palette.info.400) 'rgb-illustration-strong': '16 78 198', // #104ec6 (palette.info.600) @@ -190,16 +190,16 @@ export const clickHouseLightTheme: IThemeRGB = { // Series 'rgb-series-1': '67 126 239', // #437eef (chart.default.blue) - 'rgb-series-2': '245 90 0', // #f55a00 (palette.warning.500, one step past chart.default.orange #ff7729 (2.65:1 on white)) - 'rgb-series-3': '0 138 11', // #008a0b (palette.success.700, in place of chart.default.green #00e513 (1.72:1 on white)) + 'rgb-series-2': '255 119 41', // #ff7729 (chart.default.orange) + 'rgb-series-3': '0 229 19', // #00e513 (chart.default.green) 'rgb-series-4': '251 50 201', // #fb32c9 (chart.default.fuchsia) - 'rgb-series-5': '178 136 0', // #b28800 (palette.sunrise.600, in place of chart.default.yellow #eef400 (1.19:1 on white)) + 'rgb-series-5': '238 244 0', // #eef400 (chart.default.yellow) 'rgb-series-6': '187 51 255', // #bb33ff (chart.default.violet) - 'rgb-series-7': '0 133 153', // #008599 (palette.babyblue.600, in place of chart.default.babyblue #00cbeb (1.95:1 on white)) + 'rgb-series-7': '0 203 235', // #00cbeb (chart.default.babyblue) 'rgb-series-8': '8 155 131', // #089b83 (chart.default.teal) // Switch - 'rgb-switch-unchecked': '128 134 145', // #808691 (palette.slate.500) + 'rgb-switch-unchecked': '204 207 211', // #cccfd3 (switch.color.background.default) 'rgb-switch-thumb': '255 255 255', // #ffffff (switch.color.indicator.default) // Table @@ -259,11 +259,8 @@ export const clickHouseDarkTheme: IThemeRGB = { 'rgb-surface-tertiary-alt': '50 50 50', // #323232 (palette.neutral.712) 'rgb-surface-dialog': '31 31 28', // #1f1f1c (background.default) 'rgb-dialog-title': '249 249 249', // #f9f9f9 (dialog.color.title.default, rgb(97.5% 97.5% 97.5%)) - /** Not a Click UI value. Its dark scrim, dialog.color.opaqueBackground lch(40.731 0 none), is a - * #606060 gray that lifts the page instead of dimming it. A bundled scrim never lifts the page - * (`semanticTokens.spec.ts`), so black keeps the dark scrim; the 0.75 alpha is Click UI's. */ - 'rgb-surface-overlay': '0 0 0', // #000000 - 'rgb-surface-media-overlay': '0 0 0', // #000000 (no Click UI media scrim) + 'rgb-surface-overlay': '96 96 96', // #606060 (dialog.color.opaqueBackground, lch(40.731 0 none), drawn at the 0.75 scrimOpacity) + 'rgb-surface-media-overlay': '96 96 96', // #606060 (dialog.color.opaqueBackground, the nearest Click UI scrim; it has no media scrim) 'rgb-text-on-media': '255 255 255', // #ffffff (palette.neutral.0) 'rgb-surface-submit': '250 255 105', // #faff69 (accent.default) 'rgb-surface-submit-hover': '253 255 163', // #fdffa3 (palette.brand.200) @@ -273,8 +270,8 @@ export const clickHouseDarkTheme: IThemeRGB = { 'rgb-surface-code': '40 40 40', // #282828 (codeblock.darkMode.color.background.default) 'rgb-surface-code-body': '40 40 40', // #282828 (codeblock.darkMode.color.background.default) 'rgb-surface-code-inline': '65 65 65', // #414141 (palette.neutral.700, distinct from the user message bubble, neutral.712) - 'rgb-prose-bullet': '128 128 128', // #808080 (palette.neutral.500, 3.7:1 on the canvas; stroke.default is 1.29:1) - 'rgb-prose-quote-bar': '128 128 128', // #808080 (palette.neutral.500, 3.7:1 on the canvas) + 'rgb-prose-bullet': '65 65 65', // #414141 (stroke.intense) + 'rgb-prose-quote-bar': '65 65 65', // #414141 (stroke.intense) 'rgb-surface-qr': '255 255 255', // #ffffff (palette.neutral.0) 'rgb-surface-inverted': '250 255 105', // #faff69 (button.basic.color.primary.background.default) 'rgb-surface-inverted-hover': '252 255 150', // #fcff96 (button.basic.color.primary.background.hover) @@ -292,14 +289,14 @@ export const clickHouseDarkTheme: IThemeRGB = { '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-xheavy': '65 65 65', // #414141 (stroke.intense) '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) + 'rgb-border-control': '60 60 60', // #3c3c3c (field.color.stroke.default, rgb(23.627% 23.627% 23.627%)) 'rgb-border-field-focus': '250 255 105', // #faff69 (field.color.stroke.active) 'rgb-focus-subtle': '250 255 105', // #faff69 (outline.default) 'rgb-field-fill': '45 45 45', // rgb(17.794% 17.794% 17.794%) (field.color.background.default) 'rgb-field-text': '230 231 233', // #e6e7e9 (field.color.text.default) - 'rgb-surface-tooltip': '50 50 50', // #323232 (palette.neutral.712; tooltip.color.background.default #282828 would vanish on the #282828 canvas) + 'rgb-surface-tooltip': '40 40 40', // #282828 (tooltip.color.background.default, lch(16.114 0 none / 0.95) taken opaque) 'rgb-text-tooltip': '255 255 255', // #ffffff (tooltip.color.label.default) 'rgb-alert-error-fill': '76 32 29', // #4c201d (alert.color.background.danger, danger at 20% over background.default) 'rgb-alert-error-border': '76 32 29', // #4c201d (Click UI alerts draw no edge, so it matches the fill) @@ -348,7 +345,7 @@ export const clickHouseDarkTheme: IThemeRGB = { 'rgb-avatar-fill': '128 134 145', // #808691 (avatar.color.background.default) 'rgb-avatar-text': '31 31 28', // #1f1f1c (avatar.color.text.default) 'rgb-avatar-placeholder': '40 40 40', // #282828 (background.muted) - 'rgb-avatar-edge': '240 246 252', // #f0f6fc (Click UI avatars draw no edge) + 'rgb-avatar-edge': '50 50 50', // #323232 (stroke.default; Click UI avatars draw no edge) 'rgb-illustration-subtle': '161 190 247', // #a1bef7 (palette.info.200) 'rgb-illustration': '67 126 239', // #437eef (palette.info.400) 'rgb-illustration-strong': '16 78 198', // #104ec6 (palette.info.600) @@ -382,7 +379,7 @@ export const clickHouseDarkTheme: IThemeRGB = { 'rgb-series-8': '109 248 225', // #6df8e1 (chart.default.teal) // Switch - 'rgb-switch-unchecked': '128 128 128', // #808080 (palette.neutral.500) + 'rgb-switch-unchecked': '96 96 96', // #606060 (switch.color.background.default) 'rgb-switch-thumb': '21 21 21', // #151515 (switch.color.indicator.default) // Table @@ -408,9 +405,9 @@ export const clickHouseDarkTheme: IThemeRGB = { * Click UI's display family leads with Basier Square, a commercial face ClickHouse licenses for * its own sites. It is named here but not self-hosted: a browser that has it installed renders * headings in it, and every other one falls through to the same Inter stack as the body. The - * `text-*` scale takes Click UI's `font.sizes` 1 to 5 at its product line height of 1.5. Click UI - * has no step between 1.25rem and 2rem, and 2rem would overtake Tailwind's unthemed `text-3xl` - * (1.875rem), so `2xl` keeps LibreChat's 1.5rem at Click UI's line height. + * `text-*` scale takes Click UI's `font.sizes` 1 to 5 at its product line height of 1.5. `2xl` + * takes `font.sizes.6` (2rem), which passes Tailwind's unthemed `text-3xl` (1.875rem), at Click + * UI's line height. * * Click UI raises every elevated surface (card, dialog, menu, panel, popover, toast) with * `shadow.1`, and its only lighter step is the hairline `shadow.5`. Steps 2 to 4 are the flyout's @@ -457,7 +454,7 @@ const clickHouseShape = { textBase: '1rem', // typography.font.sizes.3 textLg: '1.125rem', // typography.font.sizes.4 textXl: '1.25rem', // typography.font.sizes.5 - text2xl: '1.5rem', // LibreChat's: Click UI's next step (sizes.6, 2rem) passes the unthemed text-3xl + text2xl: '2rem', // typography.font.sizes.6 leadingXs: '1.5', // typography.font.line-height.1 leadingSm: '1.5', // typography.font.line-height.1 leadingBase: '1.5', // typography.font.line-height.1 diff --git a/packages/client/src/theme/themes/clickui.json b/packages/client/src/theme/themes/clickui.json index fa1da6a654e..34533fbeafc 100644 --- a/packages/client/src/theme/themes/clickui.json +++ b/packages/client/src/theme/themes/clickui.json @@ -117,14 +117,12 @@ "global.color.text.link.default": "#437eef", "global.color.text.link.hover": "#104ec6", "global.color.text.muted": "#696e79", - "palette.babyblue.600": "#008599", "palette.danger.200": "#ff9898", "palette.danger.600": "#c10000", "palette.danger.700": "#910000", "palette.fuchsia.700": "#990073", "palette.info.200": "#a1bef7", "palette.info.400": "#437eef", - "palette.info.500": "#1d64ec", "palette.info.600": "#104ec6", "palette.info.700": "#0d3e9b", "palette.neutral.0": "#ffffff", @@ -139,11 +137,9 @@ "palette.success.200": "#99ffa1", "palette.success.700": "#008a0b", "palette.success.800": "#006108", - "palette.sunrise.600": "#b28800", "palette.sunrise.700": "#8a6900", "palette.violet.600": "#8800cc", "palette.warning.200": "#ffb88f", - "palette.warning.500": "#f55a00", "palette.warning.600": "#d64f00", "palette.warning.700": "#a33c00", "shadow.1": "0 4px 6px -1px lch(6.7738 0 none / 0.15), 0 2px 4px -1px lch(6.7738 0 none / 0.15)", diff --git a/packages/client/src/theme/themes/clickui.spec.ts b/packages/client/src/theme/themes/clickui.spec.ts index 55e0a7234e7..293739193d8 100644 --- a/packages/client/src/theme/themes/clickui.spec.ts +++ b/packages/client/src/theme/themes/clickui.spec.ts @@ -57,18 +57,18 @@ const modes: ThemeMode[] = ['light', 'dark']; const colorSources: Record>> = { light: { 'rgb-text-primary': 'global.color.text.default', - 'rgb-text-secondary': 'palette.slate.700', - 'rgb-text-secondary-alt': 'palette.slate.700', - 'rgb-text-tertiary': 'palette.slate.700', + 'rgb-text-secondary': 'global.color.text.muted', + 'rgb-text-secondary-alt': 'global.color.text.muted', + 'rgb-text-tertiary': 'global.color.text.muted', 'rgb-text-muted': 'global.color.text.muted', 'rgb-text-warning': 'click.global.color.text.warning', 'rgb-text-destructive': 'click.global.color.text.danger', 'rgb-shimmer-base': 'global.color.text.default', 'rgb-shimmer-dip': 'palette.slate.500', - 'rgb-link': 'palette.info.500', + 'rgb-link': 'global.color.text.link.default', 'rgb-link-hover': 'global.color.text.link.hover', 'rgb-link-visited': 'palette.violet.600', - 'rgb-link-prose': 'palette.info.500', + 'rgb-link-prose': 'global.color.text.link.default', 'rgb-accent-primary': 'global.color.accent.default', 'rgb-accent-primary-hover': 'palette.neutral.712', 'rgb-ring-primary': 'global.color.outline.default', @@ -97,6 +97,11 @@ const colorSources: Record>> 'rgb-dialog-title': 'click.dialog.color.title.default', 'rgb-badge-label': 'click.badge.opaque.color.text.default', 'rgb-surface-overlay': 'click.dialog.color.opaqueBackground.default', + 'rgb-surface-media-overlay': 'click.dialog.color.opaqueBackground.default', + 'rgb-surface-nav-hover': 'click.sidebar.main.navigation.item.color.background.hover', + 'rgb-avatar-edge': 'global.color.stroke.default', + 'rgb-alert-error-fill': 'click.alert.color.background.danger', + 'rgb-alert-error-border': 'click.alert.color.background.danger', 'rgb-surface-submit': 'global.color.accent.default', 'rgb-surface-submit-hover': 'palette.neutral.712', 'rgb-surface-destructive': 'palette.danger.600', @@ -105,8 +110,8 @@ const colorSources: Record>> 'rgb-surface-code': 'click.codeblock.lightMode.color.background.default', 'rgb-surface-code-body': 'click.codeblock.lightMode.color.background.default', 'rgb-surface-code-inline': 'palette.slate.200', - 'rgb-prose-bullet': 'palette.slate.700', - 'rgb-prose-quote-bar': 'palette.slate.700', + 'rgb-prose-bullet': 'global.color.stroke.intense', + 'rgb-prose-quote-bar': 'global.color.stroke.intense', 'rgb-surface-qr': 'palette.neutral.0', 'rgb-text-on-media': 'palette.neutral.0', 'rgb-surface-inverted': 'palette.neutral.900', @@ -122,10 +127,10 @@ const colorSources: Record>> 'rgb-border-medium': 'global.color.stroke.default', 'rgb-border-medium-alt': 'global.color.stroke.default', 'rgb-border-heavy': 'global.color.stroke.intense', - 'rgb-border-xheavy': 'palette.slate.500', + 'rgb-border-xheavy': 'global.color.stroke.intense', 'rgb-drawer-edge': 'global.color.background.split', 'rgb-border-destructive': 'palette.danger.600', - 'rgb-border-control': 'palette.slate.500', + 'rgb-border-control': 'click.field.color.stroke.default', 'rgb-border-field-focus': 'click.field.color.stroke.active', 'rgb-field-fill': 'click.field.color.background.default', 'rgb-field-text': 'click.field.color.text.default', @@ -143,11 +148,11 @@ const colorSources: Record>> 'rgb-surface-disabled': 'click.button.basic.color.primary.background.disabled', 'rgb-text-disabled': 'global.color.text.disabled', 'rgb-border-disabled': 'click.field.color.stroke.disabled', - 'rgb-status-success': 'palette.success.800', + 'rgb-status-success': 'global.color.feedback.success.foreground', 'rgb-status-success-subtle': 'global.color.feedback.success.background', 'rgb-status-success-border': 'palette.success.200', 'rgb-status-success-strong': 'global.color.feedback.success.foreground', - 'rgb-status-info': 'palette.info.600', + 'rgb-status-info': 'global.color.feedback.info.foreground', 'rgb-status-info-subtle': 'global.color.feedback.info.background', 'rgb-status-info-border': 'palette.info.200', 'rgb-status-info-strong': 'palette.info.600', @@ -188,14 +193,14 @@ const colorSources: Record>> 'rgb-syntax-attr': 'palette.fuchsia.700', 'rgb-syntax-title': 'palette.warning.700', 'rgb-series-1': 'global.color.chart.default.blue', - 'rgb-series-2': 'palette.warning.500', - 'rgb-series-3': 'palette.success.700', + 'rgb-series-2': 'global.color.chart.default.orange', + 'rgb-series-3': 'global.color.chart.default.green', 'rgb-series-4': 'global.color.chart.default.fuchsia', - 'rgb-series-5': 'palette.sunrise.600', + 'rgb-series-5': 'global.color.chart.default.yellow', 'rgb-series-6': 'global.color.chart.default.violet', - 'rgb-series-7': 'palette.babyblue.600', + 'rgb-series-7': 'global.color.chart.default.babyblue', 'rgb-series-8': 'global.color.chart.default.teal', - 'rgb-switch-unchecked': 'palette.slate.500', + 'rgb-switch-unchecked': 'click.switch.color.background.default', 'rgb-switch-thumb': 'click.switch.color.indicator.default', 'rgb-table-header-text': 'click.table.header.color.title.default', 'rgb-table-header-fill': 'click.table.header.color.background.default', @@ -243,6 +248,12 @@ const colorSources: Record>> 'rgb-surface-tertiary-alt': 'palette.neutral.712', 'rgb-surface-dialog': 'global.color.background.default', 'rgb-dialog-title': 'click.dialog.color.title.default', + 'rgb-surface-overlay': 'click.dialog.color.opaqueBackground.default', + 'rgb-surface-nav-selected': 'click.sidebar.main.navigation.item.color.background.active', + 'rgb-surface-media-overlay': 'click.dialog.color.opaqueBackground.default', + 'rgb-avatar-edge': 'global.color.stroke.default', + 'rgb-alert-error-fill': 'click.alert.color.background.danger', + 'rgb-alert-error-border': 'click.alert.color.background.danger', 'rgb-badge-label': 'click.badge.opaque.color.text.default', 'rgb-surface-submit': 'global.color.accent.default', 'rgb-surface-submit-hover': 'palette.brand.200', @@ -252,8 +263,8 @@ const colorSources: Record>> 'rgb-surface-code': 'click.codeblock.darkMode.color.background.default', 'rgb-surface-code-body': 'click.codeblock.darkMode.color.background.default', 'rgb-surface-code-inline': 'palette.neutral.700', - 'rgb-prose-bullet': 'palette.neutral.500', - 'rgb-prose-quote-bar': 'palette.neutral.500', + 'rgb-prose-bullet': 'global.color.stroke.intense', + 'rgb-prose-quote-bar': 'global.color.stroke.intense', 'rgb-surface-qr': 'palette.neutral.0', 'rgb-text-on-media': 'palette.neutral.0', 'rgb-surface-inverted': 'click.button.basic.color.primary.background.default', @@ -269,10 +280,10 @@ const colorSources: Record>> 'rgb-border-medium': 'global.color.stroke.default', 'rgb-border-medium-alt': 'global.color.stroke.default', 'rgb-border-heavy': 'global.color.stroke.intense', - 'rgb-border-xheavy': 'palette.neutral.500', + 'rgb-border-xheavy': 'global.color.stroke.intense', 'rgb-drawer-edge': 'palette.neutral.500', 'rgb-border-destructive': 'palette.danger.300', - 'rgb-border-control': 'palette.neutral.500', + 'rgb-border-control': 'click.field.color.stroke.default', 'rgb-border-field-focus': 'click.field.color.stroke.active', 'rgb-field-fill': 'click.field.color.background.default', 'rgb-field-text': 'click.field.color.text.default', @@ -342,46 +353,37 @@ const colorSources: Record>> 'rgb-series-6': 'global.color.chart.default.violet', 'rgb-series-7': 'global.color.chart.default.babyblue', 'rgb-series-8': 'global.color.chart.default.teal', - 'rgb-switch-unchecked': 'palette.neutral.500', + 'rgb-switch-unchecked': 'click.switch.color.background.default', 'rgb-switch-thumb': 'click.switch.color.indicator.default', 'rgb-table-header-text': 'click.table.header.color.title.default', 'rgb-table-header-fill': 'click.table.header.color.background.default', 'rgb-presentation': 'global.color.background.default', - 'rgb-surface-tooltip': 'palette.neutral.712', + 'rgb-surface-tooltip': 'click.tooltip.color.background.default', 'rgb-text-tooltip': 'click.tooltip.color.label.default', }, }; -/** Click UI has no scrim for media; a lightbox or preview frames the user's image in black. */ -const MEDIA_OVERLAY_REASON = - 'Click UI has no media scrim; the image frame stays black in both modes'; -const AVATAR_EDGE_REASON = - 'Click UI avatars draw no edge; the theme keeps the 10% hairline every LibreChat avatar has'; -const MEDIA_SCRIM_DEPARTURE = - 'the dialog scrim is the nearest Click UI job; a lightbox frames the user image in black instead'; - -const ALERT_ALPHA_REASON = - 'Click UI draws the danger alert as feedback.danger at 10% (light) or 20% (dark) alpha with no edge; a role holds an opaque triplet, so the theme takes the fill blended over the page and the edge equals it'; - -/** Values the theme sets on purpose without a Click UI source, and why. */ +/** Values the theme sets on purpose without a Click UI source, and why. Every role has a source today. */ const unsourcedColors: Record>> = { + light: {}, + dark: {}, +}; + +/** + * Roles hold an opaque triplet, so a Click UI token drawn with alpha reaches the page as the token + * composited on the surface it sits on. Each role here names that surface; its Click UI value is the + * composite, which is exactly what Click UI paints. + */ +const composites: Record>> = { light: { - 'rgb-surface-nav-hover': - 'Click UI sidebar item hover is a 0.6 alpha lch; the theme paints its composite on background.split', - 'rgb-surface-media-overlay': MEDIA_OVERLAY_REASON, - 'rgb-avatar-edge': AVATAR_EDGE_REASON, - 'rgb-alert-error-fill': ALERT_ALPHA_REASON, - 'rgb-alert-error-border': ALERT_ALPHA_REASON, + 'rgb-surface-nav-hover': 'global.color.background.split', + 'rgb-alert-error-fill': 'global.color.background.default', + 'rgb-alert-error-border': 'global.color.background.default', }, dark: { - 'rgb-surface-nav-selected': - 'Click UI sidebar item active is a 0.6 alpha lch; the theme paints its composite on background.split', - 'rgb-surface-overlay': - 'Click UI dark dialog.color.opaqueBackground is a gray that leaves the dialog under 3:1', - 'rgb-surface-media-overlay': MEDIA_OVERLAY_REASON, - 'rgb-avatar-edge': AVATAR_EDGE_REASON, - 'rgb-alert-error-fill': ALERT_ALPHA_REASON, - 'rgb-alert-error-border': ALERT_ALPHA_REASON, + 'rgb-surface-nav-selected': 'global.color.background.split', + 'rgb-alert-error-fill': 'global.color.background.default', + 'rgb-alert-error-border': 'global.color.background.default', }, }; @@ -404,182 +406,8 @@ interface Departure { * the counterpart. Every other role holds its counterpart, so its status is `match`. */ const departures: Record>> = { - light: { - 'rgb-alert-error-fill': { - counterpart: 'click.alert.color.background.danger', - status: 'mismatch', - reason: ALERT_ALPHA_REASON, - }, - 'rgb-alert-error-border': { - counterpart: 'click.alert.color.background.danger', - status: 'mismatch', - reason: ALERT_ALPHA_REASON, - }, - 'rgb-surface-nav-hover': { - counterpart: 'click.sidebar.main.navigation.item.color.background.hover', - status: 'near', - reason: - 'the token is lch(91.609 1.1023 265.86) at 0.6 alpha; the theme value is its composite on background.split', - }, - 'rgb-avatar-edge': { - counterpart: 'global.color.stroke.default', - status: 'near', - reason: AVATAR_EDGE_REASON, - }, - 'rgb-surface-media-overlay': { - counterpart: 'click.dialog.color.opaqueBackground.default', - status: 'near', - reason: MEDIA_SCRIM_DEPARTURE, - }, - 'rgb-text-secondary': { - counterpart: 'global.color.text.muted', - status: 'mismatch', - reason: 'text.muted is 4.05:1 on feedback.danger.background, where secondary copy also sits', - }, - 'rgb-text-secondary-alt': { - counterpart: 'global.color.text.muted', - status: 'mismatch', - reason: 'text.muted is 4.05:1 on feedback.danger.background, where secondary copy also sits', - }, - 'rgb-text-tertiary': { - counterpart: 'global.color.text.muted', - status: 'mismatch', - reason: 'text.muted is 4.05:1 on feedback.danger.background, where secondary copy also sits', - }, - 'rgb-link': { - counterpart: 'global.color.text.link.default', - status: 'mismatch', - reason: 'text.link.default is 3.84:1 on white', - }, - 'rgb-link-prose': { - counterpart: 'global.color.text.link.default', - status: 'mismatch', - reason: 'text.link.default is 3.84:1 on white', - }, - 'rgb-border-xheavy': { - counterpart: 'global.color.stroke.intense', - status: 'mismatch', - reason: 'stroke.intense is 2.03:1 on white, under the 3:1 a heavy edge carries', - }, - 'rgb-prose-bullet': { - counterpart: 'global.color.stroke.intense', - status: 'mismatch', - reason: - 'a list marker and a quote bar are the only cue of their element, so they take the 3:1 non-text floor stroke.intense (2.03:1 on white) misses', - }, - 'rgb-prose-quote-bar': { - counterpart: 'global.color.stroke.intense', - status: 'mismatch', - reason: - 'a list marker and a quote bar are the only cue of their element, so they take the 3:1 non-text floor stroke.intense (2.03:1 on white) misses', - }, - 'rgb-border-control': { - counterpart: 'click.field.color.stroke.default', - status: 'mismatch', - reason: 'field.color.stroke.default misses the 3:1 non-text floor for a control edge', - }, - 'rgb-status-success': { - counterpart: 'global.color.feedback.success.foreground', - status: 'mismatch', - reason: 'feedback.success.foreground is 4.27:1 on its own fill', - }, - 'rgb-status-info': { - counterpart: 'global.color.feedback.info.foreground', - status: 'mismatch', - reason: 'feedback.info.foreground is 3.32:1 on its own fill', - }, - 'rgb-series-2': { - counterpart: 'global.color.chart.default.orange', - status: 'mismatch', - reason: 'chart.default.orange is 2.65:1 on white, under the 3:1 a chart mark needs', - }, - 'rgb-series-3': { - counterpart: 'global.color.chart.default.green', - status: 'mismatch', - reason: 'chart.default.green is 1.72:1 on white, under the 3:1 a chart mark needs', - }, - 'rgb-series-5': { - counterpart: 'global.color.chart.default.yellow', - status: 'mismatch', - reason: 'chart.default.yellow is 1.19:1 on white, under the 3:1 a chart mark needs', - }, - 'rgb-series-7': { - counterpart: 'global.color.chart.default.babyblue', - status: 'mismatch', - reason: 'chart.default.babyblue is 1.95:1 on white, under the 3:1 a chart mark needs', - }, - 'rgb-switch-unchecked': { - counterpart: 'click.switch.color.background.default', - status: 'mismatch', - reason: 'switch.color.background.default misses the 3:1 non-text floor for the off track', - }, - }, - dark: { - 'rgb-surface-nav-selected': { - counterpart: 'click.sidebar.main.navigation.item.color.background.active', - status: 'near', - reason: - 'the token is lch(27.535 0 none) at 0.6 alpha; the theme value is its composite on background.split', - }, - 'rgb-border-xheavy': { - counterpart: 'global.color.stroke.intense', - status: 'mismatch', - reason: 'stroke.intense is 1.62:1 on the canvas, under the 3:1 a heavy edge carries', - }, - 'rgb-prose-bullet': { - counterpart: 'global.color.stroke.intense', - status: 'mismatch', - reason: - 'a list marker and a quote bar are the only cue of their element, so they take the 3:1 non-text floor stroke.intense (1.62:1 on the canvas) misses', - }, - 'rgb-prose-quote-bar': { - counterpart: 'global.color.stroke.intense', - status: 'mismatch', - reason: - 'a list marker and a quote bar are the only cue of their element, so they take the 3:1 non-text floor stroke.intense (1.62:1 on the canvas) misses', - }, - 'rgb-border-control': { - counterpart: 'click.field.color.stroke.default', - status: 'mismatch', - reason: 'field.color.stroke.default misses the 3:1 non-text floor for a control edge', - }, - 'rgb-switch-unchecked': { - counterpart: 'click.switch.color.background.default', - status: 'mismatch', - reason: 'switch.color.background.default misses the 3:1 non-text floor for the off track', - }, - 'rgb-surface-overlay': { - counterpart: 'click.dialog.color.opaqueBackground.default', - status: 'mismatch', - reason: 'the dark scrim is a #606060 gray that lifts the page instead of dimming it', - }, - 'rgb-surface-media-overlay': { - counterpart: 'click.dialog.color.opaqueBackground.default', - status: 'mismatch', - reason: MEDIA_SCRIM_DEPARTURE, - }, - 'rgb-alert-error-fill': { - counterpart: 'click.alert.color.background.danger', - status: 'mismatch', - reason: ALERT_ALPHA_REASON, - }, - 'rgb-alert-error-border': { - counterpart: 'click.alert.color.background.danger', - status: 'mismatch', - reason: ALERT_ALPHA_REASON, - }, - 'rgb-surface-tooltip': { - counterpart: 'click.tooltip.color.background.default', - status: 'near', - reason: - 'tooltip.color.background.default is the #282828 canvas in dark, which hides the chip; one step up the neutral ramp keeps white 4.5:1 and lifts it off the page', - }, - 'rgb-avatar-edge': { - counterpart: 'global.color.stroke.default', - status: 'mismatch', - reason: AVATAR_EDGE_REASON, - }, - }, + light: {}, + dark: {}, }; interface AppearanceDecision { @@ -603,13 +431,6 @@ const appearanceDecisions: Partial> = { textBase: 'typography.font.sizes.3', textLg: 'typography.font.sizes.4', textXl: 'typography.font.sizes.5', + text2xl: 'typography.font.sizes.6', leadingXs: 'typography.font.line-height.1', leadingSm: 'typography.font.line-height.1', leadingBase: 'typography.font.line-height.1', @@ -886,6 +708,23 @@ const sameRgb = (theme: string, source: Rgba) => { ); }; +/** The Click UI color a role holds: the token itself, or the token composited on its surface. */ +function sourceColor(mode: ThemeMode, key: keyof IThemeRGB, token: string): Rgba { + const color = clickColor(clickToken(mode, token)); + const surface = composites[mode][key]; + if (surface === undefined) { + return color; + } + const [r, g, b] = clickColor(clickToken(mode, surface)); + const alpha = color[3]; + return [ + color[0] * alpha + r * (1 - alpha), + color[1] * alpha + g * (1 - alpha), + color[2] * alpha + b * (1 - alpha), + 1, + ]; +} + /** sRGB channels to CIE L*a*b* under D65. */ function toLab([r, g, b]: Rgba): [number, number, number] { const linear = (value: number) => { @@ -1261,9 +1100,6 @@ const parityProbes: Record = { kind: 'color', utility: 'border', element: inputProbe, - deviation: bothModes( - 'border-control holds form controls to the 3:1 non-text floor stroke.default misses', - ), }, 'Field text': { token: 'click.field.color.text.default', @@ -1282,9 +1118,6 @@ const parityProbes: Record = { kind: 'color', utility: 'border', element: dropdownProbe, - deviation: bothModes( - 'border-control holds form controls to the 3:1 non-text floor stroke.default misses', - ), }, 'Dropdown trigger corner': { token: 'border.radii.1', @@ -1345,9 +1178,6 @@ const parityProbes: Record = { kind: 'color', utility: 'bg', element: dialogProbe('.inset-0'), - deviation: { - dark: 'surface-overlay keeps the dark scrim black; Click UI #606060 lifts the page it covers', - }, }, 'Dialog inline padding': { token: 'click.dialog.space.x', @@ -1374,10 +1204,6 @@ const parityProbes: Record = { utility: 'bg', variant: 'data-[state=unchecked]:', element: switchProbe(false), - deviation: { - light: 'switch-unchecked holds the off track to the 3:1 non-text floor #cccfd3 misses', - dark: 'switch-unchecked holds the off track to the 3:1 non-text floor #606060 misses', - }, }, 'Switch thumb': { token: 'click.switch.color.indicator.default', @@ -1430,10 +1256,6 @@ const parityProbes: Record = { kind: 'color', utility: 'border', element: checkboxProbe(false), - deviation: { - light: 'border-xheavy holds the box edge to the 3:1 non-text floor #b3b6bd misses', - dark: 'border-xheavy holds the box edge to the 3:1 non-text floor #414141 misses', - }, }, 'Checkbox corner': { token: 'click.checkbox.radii.all', @@ -1523,6 +1345,8 @@ const parityProbes: Record = { interface NotExpressible { /** The parity decisions the gap leaves unmatched, per mode. */ decisions: Partial>; + /** The appearance roles the gap leaves unmatched, in both modes. */ + appearance?: Array; /** Why no theme can reach Click UI's value today. */ reason: string; /** The berry-13/LibreChat issue that tracks the role or change that closes it. */ @@ -1542,6 +1366,13 @@ const notExpressible: Record = { 'the checkbox paints no fill of its own and shows the surface behind it; no role carries checkbox.color.background.default', issue: 'https://github.com/berry-13/LibreChat/issues/250', }, + 'Select list height': { + decisions: {}, + appearance: ['listMaxHeight'], + reason: + "Click UI caps its select list only at var(--radix-popover-content-available-height), which a length role cannot express, so listMaxHeight keeps LibreChat's 24rem", + issue: 'ISSUE_PLACEHOLDER', + }, 'Checkbox corner': { decisions: { light: ['Checkbox corner'], dark: ['Checkbox corner'] }, reason: @@ -1778,7 +1609,7 @@ function roleStatuses(mode: ThemeMode): Array<[keyof IThemeRGB, RoleStatus, stri return [key, 'mismatch', 'unsourced']; } const theme = tripletRgba(colors[key] ?? ''); - const counterpart = clickColor(clickToken(mode, token)); + const counterpart = sourceColor(mode, key, token); const distance = deltaE(theme, counterpart); const departed: RoleStatus = distance < NEAR ? 'near' : 'mismatch'; const status = sameRgb(colors[key] ?? '', counterpart) ? 'match' : departed; @@ -1840,7 +1671,7 @@ describe('ClickHouse theme drift against Click UI', () => { return [`${key}: Click UI token ${token} is not in clickui.json`]; } const theme = colors[key as keyof IThemeRGB] ?? ''; - return sameRgb(theme, parseColor(String(source))) + return sameRgb(theme, sourceColor(mode, key as keyof IThemeRGB, token)) ? [] : [`${key}: theme ${theme}, Click UI ${token} is ${source}`]; }); @@ -1892,6 +1723,7 @@ describe('ClickHouse theme drift against Click UI', () => { it.each(modes)('keeps only the %s tokens the theme cites in the snapshot', (mode) => { const cited = new Set([ ...Object.values(colorSources[mode]), + ...Object.values(composites[mode]), ...Object.values(departures[mode]).map((departure) => departure.counterpart), ...Object.values(appearanceSources), ...Object.keys(clickSpaces), @@ -1942,9 +1774,10 @@ describe('ClickHouse primitive parity against Click UI components', () => { it('keeps the not-expressible list to real, tracked, unmatched decisions', () => { const problems = Object.entries(notExpressible).flatMap( - ([gap, { decisions, reason, issue }]) => [ + ([gap, { decisions, appearance = [], reason, issue }]) => [ ...(reason.trim() === '' ? [`${gap}: no reason`] : []), - ...(Object.values(decisions).some((listed) => (listed?.length ?? 0) > 0) + ...(appearance.length > 0 || + Object.values(decisions).some((listed) => (listed?.length ?? 0) > 0) ? [] : [`${gap}: names no decision`]), ...(/^https:\/\/github\.com\/berry-13\/LibreChat\/issues\/\d+$/.test(issue) @@ -2008,10 +1841,9 @@ describe('ClickHouse primitive parity against Click UI components', () => { }); expect(moved).toEqual([]); - expect(statuses.filter(([, status]) => status === 'mismatch').map(([key]) => key)).toEqual([ - 'listMaxHeight', - 'text2xl', - ]); + expect(statuses.filter(([, status]) => status === 'mismatch').map(([key]) => key)).toEqual( + Object.values(notExpressible).flatMap(({ appearance = [] }) => appearance), + ); expect(statuses.filter(([, status]) => status === 'near').map(([key]) => key)).toEqual([]); }); }); From 4a8d1afd15d67289eb6159369335481b79d5267f Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:03:46 +0200 Subject: [PATCH 03/16] test: Link the Select List Height Gap to Its Follow-up --- packages/client/src/theme/themes/clickui.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/src/theme/themes/clickui.spec.ts b/packages/client/src/theme/themes/clickui.spec.ts index 293739193d8..3d02988bbe5 100644 --- a/packages/client/src/theme/themes/clickui.spec.ts +++ b/packages/client/src/theme/themes/clickui.spec.ts @@ -1371,7 +1371,7 @@ const notExpressible: Record = { appearance: ['listMaxHeight'], reason: "Click UI caps its select list only at var(--radix-popover-content-available-height), which a length role cannot express, so listMaxHeight keeps LibreChat's 24rem", - issue: 'ISSUE_PLACEHOLDER', + issue: 'https://github.com/berry-13/LibreChat/issues/282', }, 'Checkbox corner': { decisions: { light: ['Checkbox corner'], dark: ['Checkbox corner'] }, From 3724fdb8a7eceb1a517f51de48c553dd3d3242db Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:07:58 +0200 Subject: [PATCH 04/16] feat: Add a Sidebar Surface Role for the ClickHouse Theme --- client/src/components/Conversations/Convo.tsx | 2 +- .../UnifiedSidebar/ConversationsSection.tsx | 2 +- .../components/UnifiedSidebar/ExpandedPanel.tsx | 2 +- client/src/components/UnifiedSidebar/Sidebar.tsx | 2 +- .../components/UnifiedSidebar/UnifiedSidebar.tsx | 7 ++----- packages/client/src/theme/README.md | 4 ++-- packages/client/src/theme/defaults.css | 6 +++++- packages/client/src/theme/defaults.spec.ts | 2 +- packages/client/src/theme/registry.spec.ts | 1 + packages/client/src/theme/registry.ts | 1 + .../client/src/theme/themes/clickhouse.spec.tsx | 5 +++-- packages/client/src/theme/themes/clickhouse.ts | 12 +++++++----- packages/client/src/theme/themes/clickui.json | 16 +++++++++------- packages/client/src/theme/themes/clickui.spec.ts | 12 +++++++----- packages/client/src/theme/themes/dark.ts | 1 + packages/client/src/theme/themes/default.ts | 1 + packages/client/src/theme/themes/highContrast.ts | 2 ++ packages/client/src/theme/tokens.css | 1 + packages/client/src/theme/types/index.ts | 3 +++ packages/data-provider/src/theme.ts | 1 + 20 files changed, 51 insertions(+), 32 deletions(-) diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index 02767489190..79c98d7911b 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -429,7 +429,7 @@ function Conversation({ 'bg-status-info pointer-events-none absolute -right-0.5 -bottom-0.5 size-2 rounded-full ring-2', isActiveConvo || isPopoverActive ? 'ring-surface-nav-selected' - : 'ring-surface-primary-alt group-hover:ring-surface-nav-hover', + : 'ring-surface-sidebar group-hover:ring-surface-nav-hover', )} /> )} diff --git a/client/src/components/UnifiedSidebar/ConversationsSection.tsx b/client/src/components/UnifiedSidebar/ConversationsSection.tsx index 69f75fa96c1..0b2b1236ca2 100644 --- a/client/src/components/UnifiedSidebar/ConversationsSection.tsx +++ b/client/src/components/UnifiedSidebar/ConversationsSection.tsx @@ -282,7 +282,7 @@ const ConversationsSection = memo(() => {