Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/components/SubscriptionsList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
>
<div
:style="{
background: `${sub.color}`,
background: readableColor(sub.color, theme),
}"
class="topics-color-line"
></div>
Expand All @@ -43,7 +43,7 @@
href="javascript:;"
class="topic"
:style="{
color: sub.color,
color: readableColor(sub.color, theme),
}"
@click.stop="stopClick"
>
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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<MqttClient> = {
connected: false,
Expand Down
84 changes: 84 additions & 0 deletions src/utils/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +78 to +93

Copy link
Copy Markdown
Contributor Author

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}1A alpha-suffix idiom always yields valid CSS. 6-char input still passes through unchanged to preserve casing.

}

export default {}
89 changes: 88 additions & 1 deletion tests/unit/utils/colors.spec.ts
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', () => {
Expand All @@ -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')
})
})
Comment on lines +87 to +102

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a regression test in 124eee2normalizes 3-char shorthand to 6-char even when no clamp is needed — exercising the light-theme short-circuit path where the bug would have triggered. Full suite is 331 passing.

})
})
9 changes: 5 additions & 4 deletions web/src/components/SubscriptionsList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
>
<div
:style="{
background: `${sub.color}`,
background: readableColor(sub.color, theme),
}"
class="topics-color-line"
></div>
Expand All @@ -43,7 +43,7 @@
href="javascript:;"
class="topic"
:style="{
color: sub.color,
color: readableColor(sub.color, theme),
}"
@click.stop="stopClick"
>
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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<MqttClient> = {
connected: false,
Expand Down
84 changes: 84 additions & 0 deletions web/src/utils/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +78 to +93

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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