diff --git a/e2e/specs/mock/scenarios/saml-glyph.spec.ts b/e2e/specs/mock/scenarios/saml-glyph.spec.ts new file mode 100644 index 00000000000..1b9b1aef755 --- /dev/null +++ b/e2e/specs/mock/scenarios/saml-glyph.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test'; + +/** + * The SAML sign-in button draws a generic key glyph. It used to be filled black, which vanished + * on the dark login surface; it now takes the button's own text colour, so it reads in every + * mode and theme. SAML is switched on by rewriting `/api/config` the way librechat.yaml would. + */ +test.use({ storageState: { cookies: [], origins: [] } }); + +test('the SAML sign-in glyph is painted with the button text colour @scenario:saml-login-glyph-follows-button-text', async ({ + page, +}) => { + await page.route( + (url) => url.pathname === '/api/config', + async (route) => { + const response = await route.fetch(); + const body = await response.json(); + await route.fulfill({ + response, + json: { + ...body, + socialLoginEnabled: true, + samlLoginEnabled: true, + samlImageUrl: '', + samlLabel: 'SAML', + socialLogins: ['saml'], + }, + }); + }, + ); + + await page.goto('/login', { timeout: 15_000 }); + const button = page.getByRole('link', { name: 'SAML' }); + await expect(button).toBeVisible({ timeout: 15_000 }); + + const paint = await button.evaluate((link) => { + const glyph = link.querySelector('svg g'); + return { + text: getComputedStyle(link).color, + glyph: glyph ? getComputedStyle(glyph).fill : null, + }; + }); + expect(paint.glyph).toBe(paint.text); +}); diff --git a/eslint-suppressions.json b/eslint-suppressions.json index c356e3cb194..27d7fcfa3a8 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2385,11 +2385,6 @@ "count": 7 } }, - "packages/client/src/svgs/SamlIcon.tsx": { - "shadcn/no-raw-colors": { - "count": 1 - } - }, "packages/client/src/svgs/SendMessageIcon.tsx": { "shadcn/no-inline-styles": { "count": 1 diff --git a/packages/client/src/components/PixelCard.spec.tsx b/packages/client/src/components/PixelCard.spec.tsx new file mode 100644 index 00000000000..f47698feea3 --- /dev/null +++ b/packages/client/src/components/PixelCard.spec.tsx @@ -0,0 +1,98 @@ +import { render, waitFor } from '@testing-library/react'; +import PixelCard from './PixelCard'; + +/** The canvas draws with whatever the palette resolved to, so the test reads what the card + * asked the page for: which theme variables, and whether it asks again after a theme change. */ +describe('PixelCard palette', () => { + let fillStyles: string[]; + let channels: Record; + + beforeEach(() => { + fillStyles = []; + jest.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(function () { + return { + clearRect: jest.fn(), + fillRect: jest.fn(), + set fillStyle(value: string) { + fillStyles.push(value); + }, + } as unknown as CanvasRenderingContext2D; + }); + jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ + width: 10, + height: 10, + } as DOMRect); + channels = { + '--surface-primary-alt': '1 2 3', + '--surface-tertiary': '1 2 3', + '--border-medium': '1 2 3', + }; + const computed = window.getComputedStyle.bind(window); + jest.spyOn(window, 'getComputedStyle').mockImplementation((element) => { + const style = computed(element); + return { + ...style, + getPropertyValue: (name: string) => + name.startsWith('--') ? (channels[name] ?? '') : style.getPropertyValue(name), + } as CSSStyleDeclaration; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + document.documentElement.className = ''; + }); + + it('draws the default variant from theme variables', async () => { + render(); + + await waitFor(() => expect(fillStyles).toContain('rgb(1 2 3)')); + expect(fillStyles.every((style) => style === 'rgb(1 2 3)')).toBe(true); + }); + + it('draws each default slot from its own theme variable, and currentColor when one is unset', async () => { + channels = { '--surface-primary-alt': '1 1 1', '--surface-tertiary': '2 2 2' }; + jest + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockReturnValue({ width: 200, height: 200 } as DOMRect); + render(); + + await waitFor(() => expect(fillStyles.length).toBeGreaterThan(0)); + expect(new Set(fillStyles)).toEqual(new Set(['rgb(1 1 1)', 'rgb(2 2 2)', 'currentColor'])); + }); + + it('keeps an explicit colors prop as given', async () => { + render(); + + await waitFor(() => expect(fillStyles).toContain('#123456')); + expect(fillStyles.every((style) => style === '#123456')).toBe(true); + }); + + it('re-reads the palette when the theme changes', async () => { + render(); + await waitFor(() => expect(fillStyles).toContain('rgb(1 2 3)')); + + fillStyles = []; + channels = { + '--surface-primary-alt': '9 9 9', + '--surface-tertiary': '9 9 9', + '--border-medium': '9 9 9', + }; + document.documentElement.classList.add('dark'); + + await waitFor(() => expect(fillStyles).toContain('rgb(9 9 9)')); + }); + + it('keeps its pixels when an unrelated root variable changes', async () => { + render(); + await waitFor(() => expect(fillStyles).toContain('rgb(1 2 3)')); + const measure = HTMLElement.prototype.getBoundingClientRect as jest.Mock; + const layouts = measure.mock.calls.length; + + document.documentElement.style.setProperty('--message-scrollbar-gutter', '8px'); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(measure.mock.calls.length).toBe(layouts); + document.documentElement.style.removeProperty('--message-scrollbar-gutter'); + }); +}); diff --git a/packages/client/src/components/PixelCard.tsx b/packages/client/src/components/PixelCard.tsx index 904e2fc05b5..0661b1c2db1 100644 --- a/packages/client/src/components/PixelCard.tsx +++ b/packages/client/src/components/PixelCard.tsx @@ -139,13 +139,36 @@ const getEffectiveSpeed = (value: number, reducedMotion: boolean) => { const clamp = (n: number, min = 0, max = 1) => Math.min(Math.max(n, min), max); +/** The default palette names theme channel variables, read off the card when the pixels are + * laid out, so the canvas draws the active theme instead of a fixed light palette. */ const VARIANTS = { - default: { gap: 5, speed: 35, colors: '#f8fafc,#f1f5f9,#cbd5e1', noFocus: false }, + default: { + gap: 5, + speed: 35, + colors: '--surface-primary-alt,--surface-tertiary,--border-medium', + noFocus: false, + }, + /** Decorative presets no theme role reproduces; kept as given, like a `colors` prop. */ blue: { gap: 10, speed: 25, colors: '#e0f2fe,#7dd3fc,#0ea5e9', noFocus: false }, yellow: { gap: 3, speed: 20, colors: '#fef08a,#fde047,#eab308', noFocus: false }, pink: { gap: 6, speed: 80, colors: '#fecdd3,#fda4af,#e11d48', noFocus: true }, } as const; +/** Resolves `--token` entries to the element's channel triplet; any other entry is a CSS color + * the caller supplied and passes through. A token the page does not define falls back to the + * canvas ink rather than drawing nothing. */ +const resolvePalette = (palette: string, element: Element): string[] => { + const style = getComputedStyle(element); + return palette.split(',').map((entry) => { + const color = entry.trim(); + if (!color.startsWith('--')) { + return color; + } + const channels = style.getPropertyValue(color).trim(); + return channels ? `rgb(${channels})` : 'currentColor'; + }); +}; + interface PixelCardProps { variant?: keyof typeof VARIANTS; gap?: number; @@ -262,7 +285,7 @@ export default function PixelCard({ canvasRef.current.width = Math.floor(cw); canvasRef.current.height = Math.floor(ch); - const cols = palette.split(','); + const cols = resolvePalette(palette, containerRef.current); const px: Pixel[] = []; const cx = cw / 2; @@ -318,11 +341,34 @@ export default function PixelCard({ if (containerRef.current) { obs.observe(containerRef.current); } + /** A mode switch or an applied theme rewrites the root's class or inline variables, which + * the resolved palette has already been read from. The root's inline style also carries + * unrelated variables (scrollbar gutter, font size), so the pixels are laid out again only + * when the palette itself resolves differently. */ + let resolved = containerRef.current + ? resolvePalette(palette, containerRef.current).join(',') + : ''; + const themeObs = new MutationObserver(() => { + if (!containerRef.current) { + return; + } + const next = resolvePalette(palette, containerRef.current).join(','); + if (next === resolved) { + return; + } + resolved = next; + initPixels(); + }); + themeObs.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class', 'style'], + }); return () => { obs.disconnect(); + themeObs.disconnect(); cancelAnimationFrame(animationRef.current!); }; - }, [initPixels]); + }, [initPixels, palette]); const hoverIn = () => progressRef.current === undefined && startAnim('appear'); const hoverOut = () => progressRef.current === undefined && startAnim('disappear'); @@ -355,7 +401,7 @@ export default function PixelCard({ >