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
140 changes: 140 additions & 0 deletions doc/scratch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 22 additions & 0 deletions src/gui/src/UI/Settings/UITabAccount.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export default {
h += `<div style="overflow: hidden; display: flex; margin-bottom: 20px; flex-direction: column; align-items: center;">`;
h += `<div class="profile-picture change-profile-picture" style="background-image: url('${html_encode(window.user?.profile?.picture ?? window.icons['profile.svg'])}');">`;
h += `</div>`;
// show remove button only if user has a profile picture
if(window.user?.profile?.picture) {
h += `<button class="button remove-profile-picture" style="margin-top: 10px;">${i18n('remove_profile_picture')}</button>`;
}
h += `</div>`;

// change password button
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/gui/src/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/gui/src/i18n/translations/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
126 changes: 125 additions & 1 deletion src/gui/src/services/ThemeService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>} 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<number>} 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<number>} rgb1 - First RGB array [r, g, b] with values 0-255
* @param {Array<number>} 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<number>} rgb - RGB array [r, g, b] with values 0-255
* @param {number} alpha - Alpha value (0-1)
* @returns {Array<number>} 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<number>} 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;

Expand Down Expand Up @@ -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 () {
Expand Down Expand Up @@ -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: {
Expand Down