From 755e4e9c3f438db45debab8c6df2edeabe5d5b76 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 15 Jul 2026 09:35:12 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(defcon):=20Meshtastic=20=C3=97=20DEF?= =?UTF-8?q?=20CON=2034=20logo=20+=20accessible=20event=20theme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give the DEF CON event mode a dedicated branded header and fix low-contrast theming on dark mode. Logo - Add the full Meshtastic × DEF CON 34 crossover mark (assets/img/defcon.svg, svgo-optimised, transparent so it adapts to light/dark) and show it in LogoHeader as "M × logo", matching the Open Sauce treatment. Accessible theming - Buttons and step badges use a solid --accent instead of an accent gradient, with an auto-contrast --on-accent text colour (black/white chosen by WCAG luminance). - applyEventTheme now drives --accent from the theme's dedicated accent colour when set, falling back to primary. DEF CON's primary (#0D294A navy) is a dark brand colour that was illegible as an accent on a dark UI; its accent (#E0004E) is used instead. Other events are unchanged. - --primary-color tracks the accent so the .text-meshtastic / .bg-meshtastic / .border-meshtastic brand utilities (link/plug icons, brand buttons) stay legible. - Extract resolveEventAccentVars() (pure) and add unit tests for the accent-selection and on-accent contrast logic. --- assets/css/main.css | 15 ++++--- assets/img/defcon.svg | 1 + components/LogoHeader.vue | 55 +++++++++++++++++++++++++ utils/eventManifest.test.ts | 30 +++++++++++++- utils/eventManifest.ts | 81 ++++++++++++++++++++++++++++++------- 5 files changed, 162 insertions(+), 20 deletions(-) create mode 100644 assets/img/defcon.svg diff --git a/assets/css/main.css b/assets/css/main.css index 6d0da2f8..635e1757 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -33,6 +33,9 @@ --accent-dark: #22C55E; --accent-glow: rgba(103, 234, 148, 0.3); --accent-subtle: rgba(103, 234, 148, 0.5); + /* Legible text on a solid --accent fill (buttons, badges). Overridden per + event theme by applyEventTheme() to stay contrast-safe on any accent. */ + --on-accent: #000000; /* Button colors */ --btn-icon-bg: rgba(255, 255, 255, 0.05); @@ -329,14 +332,15 @@ /* Modern UI Components */ @layer components { - /* Gradient primary button */ + /* Solid primary button */ .btn-primary { @apply inline-flex items-center justify-center gap-2; - @apply font-normal text-sm text-black; + @apply font-normal text-sm; @apply px-5 py-2.5 rounded-xl; @apply transition-all duration-300 ease-out; @apply disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none; - background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dark) 100%); + background: var(--accent); + color: var(--on-accent); box-shadow: 0 4px 15px var(--accent-glow); } @@ -407,9 +411,10 @@ .step-badge { @apply inline-flex items-center justify-center; @apply w-8 h-8 rounded-full; - @apply text-sm font-bold text-black; + @apply text-sm font-bold; @apply mb-4; - background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dark) 100%); + background: var(--accent); + color: var(--on-accent); box-shadow: 0 0 20px var(--accent-glow); } diff --git a/assets/img/defcon.svg b/assets/img/defcon.svg new file mode 100644 index 00000000..9ab4825c --- /dev/null +++ b/assets/img/defcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/LogoHeader.vue b/components/LogoHeader.vue index 73943126..60fab483 100644 --- a/components/LogoHeader.vue +++ b/components/LogoHeader.vue @@ -100,6 +100,41 @@

+ +
+
+
+ Meshtastic Logo + Meshtastic Logo +
+ × + DEF CON 34 Logo +
+

+ {{ $t('header_title') }} +

+

+ {{ eventMode.tagline || eventMode.eventName }} +

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