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 {}