Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
79 changes: 79 additions & 0 deletions src/utils/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

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 {}
79 changes: 78 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,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

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
79 changes: 79 additions & 0 deletions web/src/utils/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

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