diff --git a/doc/scratch.md b/doc/scratch.md index 51d001c4bd..8145710eef 100644 --- a/doc/scratch.md +++ b/doc/scratch.md @@ -83,3 +83,143 @@ await (async () => { return await response.json(); })(); ``` + +## 2025-01-XX: Fix Sidebar Header Text Contrast Bug + +### Bug Description +When adjusting the lightness level of screen themes, sidebar header texts in the explorer become unreadable due to poor contrast between text and background colors. + +**Current Behavior:** +- Sidebar header text color is hardcoded to `#8f96a3` in `.window-sidebar-title` CSS class +- Sidebar background adapts to theme lightness via CSS variables +- At certain lightness values, the contrast between hardcoded text color and background becomes insufficient +- Text becomes difficult or impossible to read + +**Expected Behavior:** +- Sidebar header text should remain readable with adequate contrast across all theme lightness settings +- Should meet WCAG accessibility standards (minimum 4.5:1 contrast ratio for normal text) + +### Root Cause Analysis + +**Files Involved:** +1. `src/gui/src/css/style.css` (lines 1217-1234) + - `.window-sidebar-title` has hardcoded `color: #8f96a3;` + - Sidebar background uses: `hsla(var(--window-sidebar-hue), var(--window-sidebar-saturation), var(--window-sidebar-lightness), calc(0.5 + 0.5*var(--window-sidebar-alpha)))` + +2. `src/gui/src/services/ThemeService.js` + - Sets CSS variables: `--window-sidebar-hue`, `--window-sidebar-saturation`, `--window-sidebar-lightness`, `--window-sidebar-alpha` + - Sets `--window-sidebar-color` to `var(--primary-color)` which is either white or '#373e44' + - Does NOT set a variable for sidebar title text color + +3. `src/gui/src/css/style.css` (lines 99-103) + - CSS variables defined: `--window-sidebar-hue`, `--window-sidebar-saturation`, `--window-sidebar-lightness`, `--window-sidebar-alpha`, `--window-sidebar-color` + +### Proposed Solution + +#### Step 1: Create Contrast Calculation Utility +- **File**: `src/gui/src/services/ThemeService.js` (or create separate utility) +- **Action**: Add helper functions to: + - Convert HSL to RGB + - Calculate relative luminance (WCAG formula) + - Calculate contrast ratio between two colors + - Determine optimal text color (black or white) based on background color + - Consider sidebar background's effective color: `calc(0.5 + 0.5*alpha)` means final alpha is `0.5 + 0.5*alpha` + +#### Step 2: Calculate Sidebar Title Color Dynamically +- **File**: `src/gui/src/services/ThemeService.js` +- **Location**: In `reload_()` method +- **Action**: + - Calculate effective sidebar background color (considering alpha blend: `0.5 + 0.5*alpha`) + - Determine if background is light or dark + - Calculate appropriate text color that meets WCAG standards + - Set CSS variable `--window-sidebar-title-color` with calculated color + - Consider fallback to lighter/darker shades of the theme color if pure black/white doesn't work + +#### Step 3: Update CSS to Use Dynamic Color +- **File**: `src/gui/src/css/style.css` +- **Location**: `.window-sidebar-title` rule (line 1221) +- **Action**: + - Replace hardcoded `color: #8f96a3;` with `color: var(--window-sidebar-title-color, #8f96a3);` + - Use fallback color in case CSS variable is not set (for backwards compatibility) + +#### Step 4: Testing Plan +1. Test with various lightness values (0-100%) +2. Test with different hue and saturation values +3. Verify contrast ratio meets WCAG AA standards (4.5:1) for normal text +4. Test edge cases: + - Very light backgrounds (lig > 90%) + - Very dark backgrounds (lig < 10%) + - Medium backgrounds (lig ~ 50-60%) +5. Test with different alpha values +6. Visual regression testing - ensure text is readable at all settings + +#### Step 5: Implementation Details + +**Contrast Calculation Algorithm:** +```javascript +// Calculate relative luminance (WCAG) +function getLuminance(rgb) { + const [r, g, b] = rgb.map(val => { + val = val / 255; + return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4); + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +// Calculate contrast ratio +function getContrastRatio(color1, color2) { + const l1 = getLuminance(color1); + const l2 = getLuminance(color2); + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); +} + +// Get optimal text color +function getOptimalTextColor(backgroundColor) { + // Calculate effective background color (considering alpha blend) + // Try black and white, choose one with better contrast + const blackContrast = getContrastRatio([0, 0, 0], backgroundColor); + const whiteContrast = getContrastRatio([255, 255, 255], backgroundColor); + + if (blackContrast >= 4.5 || whiteContrast < 4.5) { + return '#000000'; // or darker shade if needed + } else { + return '#ffffff'; // or lighter shade if needed + } +} +``` + +**ThemeService Integration:** +- In `reload_()` method, after setting other CSS variables: + 1. Calculate effective sidebar background RGB + 2. Determine optimal text color + 3. Set `--window-sidebar-title-color` CSS variable + +### Files to Modify + +1. ✅ `src/gui/src/services/ThemeService.js` + - Add contrast calculation utilities + - Calculate and set `--window-sidebar-title-color` in `reload_()` + +2. ✅ `src/gui/src/css/style.css` + - Update `.window-sidebar-title` to use CSS variable with fallback + +### Alternative Approaches Considered + +1. **CSS-only solution using `mix-blend-mode`**: + - Pros: No JS needed + - Cons: Browser compatibility issues, may affect other elements + +2. **CSS `color-contrast()` function**: + - Pros: Native CSS solution + - Cons: Limited browser support as of 2024 + +3. **Predefined color palettes**: + - Pros: Simple, predictable + - Cons: May not work for all lightness values, less flexible + +### Implementation Priority +- **High**: This is an accessibility issue affecting user experience +- **Impact**: Affects all users who customize theme lightness +- **Risk**: Low - isolated change to theme service and CSS diff --git a/src/gui/src/UI/Settings/UITabAccount.js b/src/gui/src/UI/Settings/UITabAccount.js index 95de65cf62..aafe4f6ac1 100644 --- a/src/gui/src/UI/Settings/UITabAccount.js +++ b/src/gui/src/UI/Settings/UITabAccount.js @@ -37,6 +37,10 @@ export default { h += `
`; h += `
`; h += `
`; + // show remove button only if user has a profile picture + if(window.user?.profile?.picture) { + h += ``; + } h += `
`; // change password button @@ -150,6 +154,24 @@ export default { }); }) + $el_window.find('.remove-profile-picture').on('click', function (e) { + console.log('Removing profile picture...'); + + // Clear the profile picture from user's profile + update_profile(window.user.username, {picture: null}); + + // Update the profile picture display to default avatar + const defaultAvatar = window.icons['profile.svg']; + $el_window.find('.profile-picture').css('background-image', `url('${defaultAvatar}')`); + $('.profile-image').css('background-image', `url('${defaultAvatar}')`); + $('.profile-image').removeClass('profile-image-has-picture'); + + // Show the remove button (if hidden) + $el_window.find('.remove-profile-picture').show(); + + console.log('Profile picture removed successfully'); + }); + $el_window.on('file_opened', async function(e){ let selected_file = Array.isArray(e.detail) ? e.detail[0] : e.detail; // set profile picture diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css index 4c7d1637e2..3d37b6be6a 100644 --- a/src/gui/src/css/style.css +++ b/src/gui/src/css/style.css @@ -1218,7 +1218,7 @@ span.header-sort-icon img { margin: 0; font-weight: bold; font-size: 13px; - color: #8f96a3; + color: var(--window-sidebar-title-color, #8f96a3); text-shadow: 1px 1px rgb(247 247 247 / 15%); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; @@ -1243,7 +1243,7 @@ span.header-sort-icon img { margin-top: 2px; padding: 4px; border-radius: 3px; - color: #444444; + color: var(--window-sidebar-item-color, #444444); font-size: 13px; cursor: pointer; transition: 0.15s background-color; diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 613120afc0..f96a1d3c8b 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -238,6 +238,7 @@ const en = { refresh: 'Refresh', release_address_confirmation: `Are you sure you want to release this address?`, remove_from_taskbar:'Remove from Taskbar', + remove_profile_picture: 'Remove Profile Picture', rename: 'Rename', repeat: 'Repeat', replace: 'Replace', diff --git a/src/gui/src/services/ThemeService.js b/src/gui/src/services/ThemeService.js index b49d41e684..4d2e04ee31 100644 --- a/src/gui/src/services/ThemeService.js +++ b/src/gui/src/services/ThemeService.js @@ -32,6 +32,114 @@ const default_values = { light_text: false, }; +/** + * Convert HSL color to RGB array [r, g, b] with values 0-255 + * @param {number} h - Hue (0-360) + * @param {number} s - Saturation (0-100) + * @param {number} l - Lightness (0-100) + * @returns {Array} RGB array [r, g, b] with values 0-255 + */ +function hslToRgb(h, s, l) { + h = h / 360; + s = s / 100; + l = l / 100; + + let r, g, b; + + if (s === 0) { + r = g = b = l; // achromatic + } else { + const hue2rgb = (p, q, t) => { + 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; + r = hue2rgb(p, q, h + 1/3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1/3); + } + + return [ + Math.round(r * 255), + Math.round(g * 255), + Math.round(b * 255) + ]; +} + +/** + * Calculate relative luminance using WCAG formula + * @param {Array} rgb - RGB array [r, g, b] with values 0-255 + * @returns {number} Relative luminance (0-1) + */ +function getLuminance(rgb) { + const [r, g, b] = rgb.map(val => { + val = val / 255; + return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4); + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +/** + * Calculate contrast ratio between two colors (WCAG formula) + * @param {Array} rgb1 - First RGB array [r, g, b] with values 0-255 + * @param {Array} rgb2 - Second RGB array [r, g, b] with values 0-255 + * @returns {number} Contrast ratio (1-21) + */ +function getContrastRatio(rgb1, rgb2) { + const l1 = getLuminance(rgb1); + const l2 = getLuminance(rgb2); + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); +} + +/** + * Blend color with white background (simulating alpha blend on white) + * Sidebar uses: calc(0.5 + 0.5*alpha), so effective alpha is 0.5 + 0.5*alpha + * When alpha=1, effective is 1.0; when alpha=0, effective is 0.5 + * @param {Array} rgb - RGB array [r, g, b] with values 0-255 + * @param {number} alpha - Alpha value (0-1) + * @returns {Array} Blended RGB array [r, g, b] with values 0-255 + */ +function blendWithWhite(rgb, alpha) { + const effectiveAlpha = 0.5 + 0.5 * alpha; + const [r, g, b] = rgb; + return [ + Math.round(r * effectiveAlpha + 255 * (1 - effectiveAlpha)), + Math.round(g * effectiveAlpha + 255 * (1 - effectiveAlpha)), + Math.round(b * effectiveAlpha + 255 * (1 - effectiveAlpha)) + ]; +} + +/** + * Determine optimal text color (black or white) based on background color + * Returns the color that provides better contrast meeting WCAG AA standards (4.5:1) + * @param {Array} backgroundColor - RGB array [r, g, b] with values 0-255 + * @returns {string} Hex color string ('#000000' for black, '#ffffff' for white) + */ +function getOptimalTextColor(backgroundColor) { + const black = [0, 0, 0]; + const white = [255, 255, 255]; + + const blackContrast = getContrastRatio(black, backgroundColor); + const whiteContrast = getContrastRatio(white, backgroundColor); + + // Choose the color with better contrast + // If both meet 4.5:1, prefer the one with higher contrast + // If neither meets 4.5:1, still choose the better one + if (blackContrast >= whiteContrast) { + return '#000000'; + } else { + return '#ffffff'; + } +} + export class ThemeService extends Service { #broadcastService; @@ -86,8 +194,9 @@ export class ThemeService extends Service { ...this.state, ...data.colors, }; - this.reload_(); } + // Always reload to set initial CSS variables + this.reload_(); } reset () { @@ -122,6 +231,21 @@ export class ThemeService extends Service { this.root.style.setProperty('--primary-alpha', s.alpha); this.root.style.setProperty('--primary-color', s.light_text ? 'white' : '#373e44'); + // Calculate optimal sidebar colors based on effective background color + // Sidebar background uses: calc(0.5 + 0.5*alpha), so we need to blend with white + try { + const sidebarRgb = hslToRgb(s.hue, s.sat, s.lig); + const blendedSidebarRgb = blendWithWhite(sidebarRgb, s.alpha); + const sidebarTextColor = getOptimalTextColor(blendedSidebarRgb); + + // Set CSS variables for both sidebar title and sidebar items + this.root.style.setProperty('--window-sidebar-title-color', sidebarTextColor); + this.root.style.setProperty('--window-sidebar-item-color', sidebarTextColor); + console.log('[ThemeService] Sidebar text colors set to:', sidebarTextColor); + } catch (error) { + console.error('[ThemeService] Error calculating sidebar colors:', error); + } + // TODO: Should we debounce this to reduce traffic? this.#broadcastService.sendBroadcast('themeChanged', { palette: {