-
Notifications
You must be signed in to change notification settings - Fork 545
fix: make topic colors legible across dark, night, and light themes #2050
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
0a37080
9b23fc2
124eee2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,30 @@ | ||
| import { expect } from 'chai' | ||
| import { defineColors, getRandomColor } from '@/utils/colors' | ||
| import { defineColors, getRandomColor, readableColor } from '@/utils/colors' | ||
|
|
||
| const hexToRgb = (hex: string): [number, number, number] => { | ||
| const h = hex.replace('#', '') | ||
| return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)] | ||
| } | ||
|
|
||
| const lightness = (hex: string): number => { | ||
| const [r, g, b] = hexToRgb(hex).map((v) => v / 255) | ||
| const max = Math.max(r, g, b) | ||
| const min = Math.min(r, g, b) | ||
| return (max + min) / 2 | ||
| } | ||
|
|
||
| const hue = (hex: string): number => { | ||
| const [r, g, b] = hexToRgb(hex).map((v) => v / 255) | ||
| const max = Math.max(r, g, b) | ||
| const min = Math.min(r, g, b) | ||
| if (max === min) return 0 | ||
| const d = max - min | ||
| let h = 0 | ||
| if (max === r) h = (g - b) / d + (g < b ? 6 : 0) | ||
| else if (max === g) h = (b - r) / d + 2 | ||
| else h = (r - g) / d + 4 | ||
| return h / 6 | ||
| } | ||
|
|
||
| describe('colors utility functions', () => { | ||
| it('defineColors should have 5 predefined colors', () => { | ||
|
|
@@ -14,4 +39,56 @@ describe('colors utility functions', () => { | |
| const randomColor = getRandomColor() | ||
| expect(randomColor).to.match(/^#[0-9A-F]{6}$/) | ||
| }) | ||
|
|
||
| describe('readableColor', () => { | ||
| describe('on dark or night theme', () => { | ||
| it('lightens a very dark color to L >= 0.6', () => { | ||
| // The reported failing case: dark indigo on the Night theme | ||
| const out = readableColor('#0F003A', 'dark' as Theme) | ||
| expect(lightness(out)).to.be.at.least(0.6 - 1e-6) | ||
| }) | ||
|
|
||
| it('applies the same clamp on night as on dark', () => { | ||
| const out = readableColor('#0F003A', 'night' as Theme) | ||
| expect(lightness(out)).to.be.at.least(0.6 - 1e-6) | ||
| }) | ||
|
|
||
| it('preserves hue when lightening', () => { | ||
| const out = readableColor('#0F003A', 'dark' as Theme) | ||
| expect(Math.abs(hue(out) - hue('#0F003A'))).to.be.lessThan(0.01) | ||
| }) | ||
|
|
||
| it('returns colors already in the readable band unchanged', () => { | ||
| // Light cyan from the predefined palette — already legible on dark bg | ||
| expect(readableColor('#6ECBEE', 'dark' as Theme)).to.equal('#6ECBEE') | ||
| }) | ||
| }) | ||
|
|
||
| describe('on light theme', () => { | ||
| it('darkens a near-white color to L <= 0.55', () => { | ||
| const out = readableColor('#F5F5F5', 'light' as Theme) | ||
| expect(lightness(out)).to.be.at.most(0.55 + 1e-6) | ||
| }) | ||
|
|
||
| it('returns dark colors unchanged', () => { | ||
| expect(readableColor('#0F003A', 'light' as Theme)).to.equal('#0F003A') | ||
| }) | ||
| }) | ||
|
|
||
| describe('input handling', () => { | ||
| it('returns empty input unchanged', () => { | ||
| expect(readableColor('', 'dark' as Theme)).to.equal('') | ||
| }) | ||
|
|
||
| it('returns non-hex input unchanged', () => { | ||
| expect(readableColor('not-a-color', 'dark' as Theme)).to.equal('not-a-color') | ||
| }) | ||
|
|
||
| it('accepts 3-character shorthand hex', () => { | ||
| // #003 expands to #000033 — very dark, should be lightened on dark theme | ||
| const out = readableColor('#003', 'dark' as Theme) | ||
| expect(lightness(out)).to.be.at.least(0.6 - 1e-6) | ||
| }) | ||
| }) | ||
|
Comment on lines
+87
to
+102
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added a regression test in 124eee2 — |
||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,4 +9,83 @@ export const getRandomColor = (): string => { | |
| return color | ||
| } | ||
|
|
||
| const hexToRgb = (hex: string): [number, number, number] | null => { | ||
| const m = hex.replace('#', '').match(/^([0-9a-f]{6}|[0-9a-f]{3})$/i) | ||
| if (!m) return null | ||
| let h = m[1] | ||
| if (h.length === 3) | ||
| h = h | ||
| .split('') | ||
| .map((c) => c + c) | ||
| .join('') | ||
| return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)] | ||
| } | ||
|
|
||
| const rgbToHex = (r: number, g: number, b: number): string => { | ||
| const clamp = (n: number) => Math.max(0, Math.min(255, Math.round(n))) | ||
| return '#' + [r, g, b].map((n) => clamp(n).toString(16).padStart(2, '0')).join('') | ||
| } | ||
|
|
||
| const rgbToHsl = ([r, g, b]: [number, number, number]): [number, number, number] => { | ||
| r /= 255 | ||
| g /= 255 | ||
| b /= 255 | ||
| const max = Math.max(r, g, b) | ||
| const min = Math.min(r, g, b) | ||
| const l = (max + min) / 2 | ||
| let h = 0 | ||
| let s = 0 | ||
| if (max !== min) { | ||
| const d = max - min | ||
| s = l > 0.5 ? d / (2 - max - min) : d / (max + min) | ||
| switch (max) { | ||
| case r: | ||
| h = (g - b) / d + (g < b ? 6 : 0) | ||
| break | ||
| case g: | ||
| h = (b - r) / d + 2 | ||
| break | ||
| case b: | ||
| h = (r - g) / d + 4 | ||
| break | ||
| } | ||
| h /= 6 | ||
| } | ||
| return [h, s, l] | ||
| } | ||
|
|
||
| const hslToRgb = ([h, s, l]: [number, number, number]): [number, number, number] => { | ||
| if (s === 0) { | ||
| const v = l * 255 | ||
| return [v, v, v] | ||
| } | ||
| const hue2rgb = (p: number, q: number, t: number) => { | ||
| if (t < 0) t += 1 | ||
| if (t > 1) t -= 1 | ||
| if (t < 1 / 6) return p + (q - p) * 6 * t | ||
| if (t < 1 / 2) return q | ||
| if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6 | ||
| return p | ||
| } | ||
| const q = l < 0.5 ? l * (1 + s) : l + s - l * s | ||
| const p = 2 * l - q | ||
| return [hue2rgb(p, q, h + 1 / 3) * 255, hue2rgb(p, q, h) * 255, hue2rgb(p, q, h - 1 / 3) * 255] | ||
| } | ||
|
|
||
| // Clamp HSL lightness so a topic color stays legible against the current | ||
| // theme's background. Hue and saturation are preserved, so a "blue" topic | ||
| // stays blue — only the lightness shifts into a readable band. | ||
| export const readableColor = (hex: string, theme: Theme): string => { | ||
| if (!hex) return hex | ||
| const rgb = hexToRgb(hex) | ||
| if (!rgb) return hex | ||
| const [h, s, l] = rgbToHsl(rgb) | ||
| const minL = theme === 'light' ? 0 : 0.6 | ||
| const maxL = theme === 'light' ? 0.55 : 1 | ||
| const newL = Math.max(minL, Math.min(maxL, l)) | ||
| if (newL === l) return hex | ||
| const [r, g, b] = hslToRgb([h, s, newL]) | ||
| return rgbToHex(r, g, b) | ||
|
Comment on lines
+78
to
+93
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same fix applied to the web copy in 124eee2 — symmetric with the desktop change. |
||
| } | ||
|
|
||
| export default {} | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch — thanks. Fixed in 124eee2: when the early-return path is hit, the helper now normalizes 3-char shorthand to 6-char so the
${color}1Aalpha-suffix idiom always yields valid CSS. 6-char input still passes through unchanged to preserve casing.