diff --git a/src/components/SubscriptionsList.vue b/src/components/SubscriptionsList.vue index e6606abc8..452807cb7 100644 --- a/src/components/SubscriptionsList.vue +++ b/src/components/SubscriptionsList.vue @@ -18,14 +18,14 @@ :key="index" :class="['topics-item', { active: index === topicActiveIndex, disabled: sub.disabled }]" :style="{ - background: `${sub.color}10`, + background: `${readableColor(sub.color, theme)}1A`, }" @click="handleClickTopic(sub, index)" @contextmenu.prevent="handleContextMenu(sub, $event)" >
@@ -43,7 +43,7 @@ href="javascript:;" class="topic" :style="{ - color: sub.color, + color: readableColor(sub.color, theme), }" @click.stop="stopClick" > @@ -232,7 +232,7 @@ import { MqttClient } from 'mqtt' import { Getter, Action } from 'vuex-class' import VueI18n from 'vue-i18n' import _ from 'lodash' -import { defineColors, getRandomColor } from '@/utils/colors' +import { defineColors, getRandomColor, readableColor } from '@/utils/colors' import LeftPanel from '@/components/LeftPanel.vue' import MyDialog from '@/components/MyDialog.vue' import Contextmenu from '@/components/Contextmenu.vue' @@ -264,6 +264,7 @@ export default class SubscriptionsList extends Vue { @Getter('autoResub') private autoResub!: boolean @Getter('topicWhitespaceDetection') private topicWhitespaceDetection!: boolean + private readableColor = readableColor private topicColor = '' private client: Partial = { connected: false, diff --git a/src/utils/colors.ts b/src/utils/colors.ts index e97b90c3f..eeb10845f 100644 --- a/src/utils/colors.ts +++ b/src/utils/colors.ts @@ -9,4 +9,88 @@ 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) { + // Already in the readable band. Normalize 3-char shorthand to 6-char so + // callers appending an alpha suffix (e.g. `${color}1A`) always produce + // valid CSS; pass 6-char input through unchanged to preserve casing. + return hex.replace('#', '').length === 3 ? rgbToHex(rgb[0], rgb[1], rgb[2]) : hex + } + const [r, g, b] = hslToRgb([h, s, newL]) + return rgbToHex(r, g, b) +} + export default {} diff --git a/tests/unit/utils/colors.spec.ts b/tests/unit/utils/colors.spec.ts index 9b1273af3..520eb7990 100644 --- a/tests/unit/utils/colors.spec.ts +++ b/tests/unit/utils/colors.spec.ts @@ -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,66 @@ 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) + }) + + it('normalizes 3-char shorthand to 6-char even when no clamp is needed', () => { + // #003 expands to #000033 (L≈0.10) — already legible on the light + // theme so no lightness clamp applies. Output must still be 6-char + // because callers append an alpha suffix (`${color}1A`); a 3-char + // return would yield invalid CSS like `#0031A`. + const out = readableColor('#003', 'light' as Theme) + expect(out).to.match(/^#[0-9a-f]{6}$/) + expect(out).to.equal('#000033') + }) + }) + }) }) diff --git a/web/src/components/SubscriptionsList.vue b/web/src/components/SubscriptionsList.vue index 8a9c4eaeb..79ad61d17 100644 --- a/web/src/components/SubscriptionsList.vue +++ b/web/src/components/SubscriptionsList.vue @@ -19,14 +19,14 @@ :key="index" :class="['topics-item', { active: index === topicActiveIndex, disabled: sub.disabled }]" :style="{ - background: `${sub.color}10`, + background: `${readableColor(sub.color, theme)}1A`, }" @click="handleClickTopic(sub, index)" @contextmenu.prevent="handleContextMenu(sub, $event)" >
@@ -43,7 +43,7 @@ href="javascript:;" class="topic" :style="{ - color: sub.color, + color: readableColor(sub.color, theme), }" @click.stop="stopClick" > @@ -221,7 +221,7 @@ import { MqttClient } from 'mqtt' import { Getter, Action } from 'vuex-class' import VueI18n from 'vue-i18n' import _ from 'lodash' -import { defineColors, getRandomColor } from '@/utils/colors' +import { defineColors, getRandomColor, readableColor } from '@/utils/colors' import LeftPanel from '@/components/LeftPanel.vue' import MyDialog from '@/components/MyDialog.vue' import Contextmenu from '@/components/Contextmenu.vue' @@ -250,6 +250,7 @@ export default class SubscriptionsList extends Vue { @Getter('activeConnection') private activeConnection!: ActiveConnection @Getter('topicWhitespaceDetection') private topicWhitespaceDetection!: boolean + private readableColor = readableColor private topicColor = '' private client: Partial = { connected: false, diff --git a/web/src/utils/colors.ts b/web/src/utils/colors.ts index e97b90c3f..eeb10845f 100644 --- a/web/src/utils/colors.ts +++ b/web/src/utils/colors.ts @@ -9,4 +9,88 @@ 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) { + // Already in the readable band. Normalize 3-char shorthand to 6-char so + // callers appending an alpha suffix (e.g. `${color}1A`) always produce + // valid CSS; pass 6-char input through unchanged to preserve casing. + return hex.replace('#', '').length === 3 ? rgbToHex(rgb[0], rgb[1], rgb[2]) : hex + } + const [r, g, b] = hslToRgb([h, s, newL]) + return rgbToHex(r, g, b) +} + export default {}