diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index 0444244d4..e859e3c70 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -214,6 +214,15 @@ export function renderCollaborationSelection( /** Select black or white text using the WCAG relative-luminance threshold. */ export function contrastingTextColor(hexColor: string): '#000000' | '#ffffff' { + if ( + typeof hexColor !== 'string' || + !CURSOR_COLOR_PATTERN.test(hexColor) + ) { + throw new RangeError( + 'collaboration contrast color must be a six-digit hexadecimal color', + ); + } + const red = Number.parseInt(hexColor.slice(1, 3), 16) / 255; const green = Number.parseInt(hexColor.slice(3, 5), 16) / 255; const blue = Number.parseInt(hexColor.slice(5, 7), 16) / 255; diff --git a/src/collaboration/awarenessContrastColor.test.ts b/src/collaboration/awarenessContrastColor.test.ts new file mode 100644 index 000000000..c1d6a1f2f --- /dev/null +++ b/src/collaboration/awarenessContrastColor.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { contrastingTextColor } from './awareness.js'; + +const INVALID_CONTRAST_COLOR_ERROR = new RangeError( + 'collaboration contrast color must be a six-digit hexadecimal color', +); + +describe('public collaboration contrast-color contract', () => { + it.each(['#fff', '#zzzzzz', 'red', ''])( + 'rejects malformed color token %j instead of returning a plausible contrast', + (color) => { + expect(() => contrastingTextColor(color)).toThrowError( + INVALID_CONTRAST_COLOR_ERROR, + ); + }, + ); + + it('rejects non-string runtime input without coercion', () => { + expect(() => contrastingTextColor(7 as never)).toThrowError( + INVALID_CONTRAST_COLOR_ERROR, + ); + }); + + it('preserves valid uppercase and lowercase six-digit colors', () => { + expect(contrastingTextColor('#FFFFFF')).toBe('#000000'); + expect(contrastingTextColor('#000000')).toBe('#ffffff'); + expect(contrastingTextColor('#777777')).toBe('#000000'); + }); +});