{
})
})
+describe('resolveEventAccentVars', () => {
+ it('prefers a dedicated accent over a dark primary (DEF CON) and uses white on-accent', () => {
+ const vars = resolveEventAccentVars({ colors: { primary: '#0D294A', secondary: '#017FA4', accent: '#E0004E' } })
+ expect(vars?.['--accent']).toBe('#E0004E')
+ expect(vars?.['--on-accent']).toBe('#ffffff')
+ // The brand-colour utilities track the accent, not the dark navy primary,
+ // so .text-meshtastic icons/links stay legible on the dark UI.
+ expect(vars?.['--primary-color']).toBe('#E0004E')
+ // accent-dark is derived from the accent hue, not the (teal) secondary.
+ expect(vars?.['--accent-dark']).not.toBe('#017FA4')
+ })
+
+ it('falls back to primary when no dedicated accent is set', () => {
+ const vars = resolveEventAccentVars({ colors: { primary: '#E94F1D' } })
+ expect(vars?.['--accent']).toBe('#E94F1D')
+ })
+
+ it('picks black on-accent for a light accent, white for a dark one', () => {
+ expect(resolveEventAccentVars({ colors: { primary: '#67ea94' } })?.['--on-accent']).toBe('#000000')
+ expect(resolveEventAccentVars({ colors: { primary: '#0D294A' } })?.['--on-accent']).toBe('#ffffff')
+ })
+
+ it('returns null for a missing or unparseable theme', () => {
+ expect(resolveEventAccentVars(null)).toBeNull()
+ expect(resolveEventAccentVars({ colors: { primary: 'not-a-color' } })).toBeNull()
+ })
+})
+
describe('isFirmwareDowngrade', () => {
const shipped = manifestEditionToEventMode(shippedEdition)
const comingSoon = manifestEditionToEventMode(comingSoonEdition)
diff --git a/utils/eventManifest.ts b/utils/eventManifest.ts
index fc2c9588..8df58127 100644
--- a/utils/eventManifest.ts
+++ b/utils/eventManifest.ts
@@ -140,25 +140,78 @@ function darken(hex: string, amount: number): string | null {
return `#${[r, g, b].map(c => c.toString(16).padStart(2, '0')).join('')}`
}
+/** WCAG relative luminance of an [r, g, b] triple (0–255 channels). */
+function relativeLuminance([r, g, b]: [number, number, number]): number {
+ const channel = (c: number) => {
+ const s = c / 255
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
+ }
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
+}
+
+/**
+ * Black or white text — whichever has more contrast against `hex`. Used for
+ * content sitting ON a solid accent fill (buttons, step badges) so a dark event
+ * accent (e.g. DEF CON) doesn't leave black text on a dark button.
+ */
+function onAccentText(hex: string): string {
+ const rgb = parseHex(hex)
+ if (!rgb) return '#000000'
+ const lum = relativeLuminance(rgb)
+ const contrastWhite = 1.05 / (lum + 0.05)
+ const contrastBlack = (lum + 0.05) / 0.05
+ return contrastWhite >= contrastBlack ? '#ffffff' : '#000000'
+}
+
/**
* Re-tint the UI from the edition theme by overriding the accent CSS variables
* defined in assets/css/main.css. The whole interface (logo glow, gradient
* title, buttons) reads these, so this is all the wiring branding needs.
*/
-export function applyEventTheme(theme?: EventFirmwareTheme | null): void {
+/**
+ * Resolve the accent CSS variables an event theme drives. Pure (no DOM) so the
+ * accent-selection and on-accent contrast logic is unit-testable. Returns null
+ * when the theme has no usable primary colour.
+ */
+export function resolveEventAccentVars(theme?: EventFirmwareTheme | null): Record | null {
const primary = theme?.colors?.primary
- if (!primary || !parseHex(primary)) return
+ if (!primary || !parseHex(primary)) return null
+ // The interactive accent drives buttons, badges, borders, glows and the
+ // gradient title. Prefer the theme's dedicated accent colour when set — a
+ // theme's `primary` can be a dark brand/background colour (e.g. DEF CON's
+ // navy) that is illegible as an accent on a dark UI. Fall back to primary for
+ // themes that only specify one colour (Hamvention, Open Sauce, …).
+ const accent = theme?.colors?.accent && parseHex(theme.colors.accent)
+ ? theme.colors.accent
+ : primary
+
+ const vars: Record = {
+ '--accent': accent,
+ // Legible text for content sitting on the solid accent (buttons, badges).
+ '--on-accent': onAccentText(accent),
+ // The brand-colour utilities (.text-meshtastic / .bg-meshtastic /
+ // .border-meshtastic, link + plug icons, etc.) read --primary-color. In the
+ // default theme it equals --accent, so keep them unified here too — using
+ // the raw (possibly dark) primary would leave those icons illegible on a
+ // dark UI (e.g. DEF CON's navy link icon).
+ '--primary-color': accent,
+ }
+ // Keep the gradient title cohesive: darken the SAME accent hue rather than
+ // jumping to the (possibly unrelated) secondary colour.
+ const accentDark = darken(accent, 0.25)
+ if (accentDark) vars['--accent-dark'] = accentDark
+ const glow = hexToRgba(accent, 0.3)
+ if (glow) vars['--accent-glow'] = glow
+ const subtle = hexToRgba(accent, 0.5)
+ if (subtle) vars['--accent-subtle'] = subtle
+ return vars
+}
+
+export function applyEventTheme(theme?: EventFirmwareTheme | null): void {
+ const vars = resolveEventAccentVars(theme)
+ if (!vars) return
const root = document.documentElement
- const secondary = theme?.colors?.secondary && parseHex(theme.colors.secondary)
- ? theme.colors.secondary
- : darken(primary, 0.25)
-
- root.style.setProperty('--accent', primary)
- if (secondary) root.style.setProperty('--accent-dark', secondary)
- const glow = hexToRgba(primary, 0.3)
- if (glow) root.style.setProperty('--accent-glow', glow)
- const subtle = hexToRgba(primary, 0.5)
- if (subtle) root.style.setProperty('--accent-subtle', subtle)
- // Also feed the manifest-theme.css variables some components reference.
- root.style.setProperty('--primary-color', primary)
+ for (const [key, value] of Object.entries(vars)) {
+ root.style.setProperty(key, value)
+ }
}
From 84664d0654d6e9f5c8f5d38000df15ca28578ace Mon Sep 17 00:00:00 2001
From: Ben Meadors
Date: Wed, 15 Jul 2026 09:50:13 -0500
Subject: [PATCH 2/2] docs(defcon): move applyEventTheme JSDoc to the right
function
The applyEventTheme doc block was left stranded above resolveEventAccentVars
when that pure helper was extracted; move it back down. (CodeRabbit #376)
---
utils/eventManifest.ts | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/utils/eventManifest.ts b/utils/eventManifest.ts
index 8df58127..8a712990 100644
--- a/utils/eventManifest.ts
+++ b/utils/eventManifest.ts
@@ -163,11 +163,6 @@ function onAccentText(hex: string): string {
return contrastWhite >= contrastBlack ? '#ffffff' : '#000000'
}
-/**
- * Re-tint the UI from the edition theme by overriding the accent CSS variables
- * defined in assets/css/main.css. The whole interface (logo glow, gradient
- * title, buttons) reads these, so this is all the wiring branding needs.
- */
/**
* Resolve the accent CSS variables an event theme drives. Pure (no DOM) so the
* accent-selection and on-accent contrast logic is unit-testable. Returns null
@@ -207,6 +202,11 @@ export function resolveEventAccentVars(theme?: EventFirmwareTheme | null): Recor
return vars
}
+/**
+ * Re-tint the UI from the edition theme by overriding the accent CSS variables
+ * defined in assets/css/main.css. The whole interface (logo glow, gradient
+ * title, buttons) reads these, so this is all the wiring branding needs.
+ */
export function applyEventTheme(theme?: EventFirmwareTheme | null): void {
const vars = resolveEventAccentVars(theme)
if (!vars) return