From 80efffadceae0edb36866743011cbcbf52d45ba6 Mon Sep 17 00:00:00 2001 From: Elias Wainberg Date: Tue, 7 Oct 2025 15:01:41 -0400 Subject: [PATCH 01/14] Initial commit - Adds initial gradient support - Begins work on getting / modifying backgrounds from parent elements --- assets/js/Components/Forms/ContrastForm.js | 287 +++++++++++-------- assets/js/Components/Widgets/UfixitWidget.js | 29 +- assets/js/Services/Contrast.js | 13 +- 3 files changed, 212 insertions(+), 117 deletions(-) diff --git a/assets/js/Components/Forms/ContrastForm.js b/assets/js/Components/Forms/ContrastForm.js index 064211839..125dfd92b 100644 --- a/assets/js/Components/Forms/ContrastForm.js +++ b/assets/js/Components/Forms/ContrastForm.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useRef } from 'react' import DarkIcon from '../Icons/DarkIcon' import LightIcon from '../Icons/LightIcon' import SeverityIssueIcon from '../Icons/SeverityIssueIcon' @@ -16,138 +16,186 @@ export default function ContrastForm({ handleActiveIssue, handleIssueSave, markAsReviewed, - setMarkAsReviewed + setMarkAsReviewed, + parentBackground = false }) { + console.log("parentBackground", parentBackground) + // Extract color strings from gradients + const extractColors = (gradientString) => { + const colorRegex = /#(?:[0-9a-fA-F]{3,8})\b|(?:rgba?|hsla?)\([^)]*\)/g + return gradientString.match(colorRegex) || [] + } - const getBackgroundColor = () => { - const issue = activeIssue - const metadata = (issue.metadata) ? JSON.parse(issue.metadata) : {} + // Get all background colors (including gradients) + const getBackgroundColors = () => { const html = Html.getIssueHtml(activeIssue) - const element = Html.toElement(html) - - if (element?.style?.backgroundColor) { - return Contrast.standardizeColor(element.style.backgroundColor) + console.log("html", html) + let element = Html.toElement(html) + if (!element) return [] + + let tempBackgroundColors = [] + let current = element + let rawStyle = '' + let bgMatch = null + + // Traverse up the DOM to find the first background(-color|-image) + while (current) { + rawStyle = current.getAttribute && current.getAttribute('style') || '' + bgMatch = rawStyle.match(/background(-color|-image)?:\s*([^;]+);?/i) + if (bgMatch) break + current = current.parentElement } - else { - return (metadata.backgroundColor) ? Contrast.standardizeColor(metadata.backgroundColor) : settings.backgroundColor + + if (bgMatch) { + const styleValue = bgMatch[2] + const colors = extractColors(styleValue) + colors.forEach(color => { + const standardColor = Contrast.standardizeColor(color) + if (standardColor) { + tempBackgroundColors.push({ + originalString: styleValue, + originalColorString: color, + standardColor + }) + } + }) + } + + if (tempBackgroundColors.length === 0) { + tempBackgroundColors.push({ + originalString: '', + originalColorString: settings.backgroundColor, + standardColor: settings.backgroundColor + }) } + return tempBackgroundColors } + // Get initial text color const getTextColor = () => { - const issue = activeIssue - const metadata = (issue.metadata) ? JSON.parse(issue.metadata) : {} + const metadata = activeIssue.metadata ? JSON.parse(activeIssue.metadata) : {} const html = Html.getIssueHtml(activeIssue) + console.log(html) const element = Html.toElement(html) - if (element.style.color) { return Contrast.standardizeColor(element.style.color) } - else { - return (metadata.color) ? Contrast.standardizeColor(metadata.color) : settings.textColor - } + return metadata.color ? Contrast.standardizeColor(metadata.color) : settings.textColor } - const initialBackgroundColor = getBackgroundColor() - const initialTextColor = getTextColor() + // Heading tags for contrast threshold const headingTags = ["H1", "H2", "H3", "H4", "H5", "H6"] - const [backgroundColor, setBackgroundColor] = useState(initialBackgroundColor) - const [textColor, setTextColor] = useState(initialTextColor) + // State + const [originalBgColors, setOriginalBgColors] = useState([]) + const [currentBgColors, setCurrentBgColors] = useState([]) + const [textColor, setTextColor] = useState('') const [contrastRatio, setContrastRatio] = useState(null) const [ratioIsValid, setRatioIsValid] = useState(false) - - const isValidHexColor = (color) => { - const hexColorPattern = /^#?([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/ - let outcome = hexColorPattern.test(color) - return outcome - } - const processHtml = (html) => { - let element = Html.toElement(html) + // Validate hex color + const isValidHexColor = (color) => /^#?([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/.test(color) - element.style.backgroundColor = Contrast.convertShortenedHex(backgroundColor) + // Generate updated HTML with new colors + const processHtml = (html, bgColors) => { + let element = Html.toElement(html) + if (bgColors.length > 1) { + let gradientHtml = originalBgColors[0].originalString + originalBgColors.forEach((bg, idx) => { + gradientHtml = gradientHtml.replace(bg.originalColorString, bgColors[idx]) + }) + element.style.background = gradientHtml + element.style.backgroundColor = '' + } else if (bgColors.length === 1) { + element.style.backgroundColor = Contrast.convertShortenedHex(bgColors[0]) + } else { + element.style.background = '' + } element.style.color = Contrast.convertShortenedHex(textColor) - return Html.toString(element) } + // Update preview and contrast ratio const updatePreview = () => { - let issue = activeIssue const html = Html.getIssueHtml(activeIssue) - let contrastRatio = Contrast.contrastRatio(backgroundColor, textColor) - let tagName = Html.toElement(html).tagName - let ratioIsValid = ratioIsValid - - if(headingTags.includes(tagName)) { - ratioIsValid = (contrastRatio >= 3) - } else { - ratioIsValid = (contrastRatio >= 4.5) + let ratio = 1 + if (currentBgColors.length > 0 && textColor) { + const ratios = currentBgColors.map(bg => Contrast.contrastRatio(bg, textColor)) + ratio = Math.min(...ratios) } + const tagName = Html.toElement(html).tagName + const valid = headingTags.includes(tagName) ? (ratio >= 3) : (ratio >= 4.5) + setContrastRatio(ratio) + setRatioIsValid(valid) - setContrastRatio(contrastRatio) - setRatioIsValid(ratioIsValid) - - issue.newHtml = processHtml(html) - handleActiveIssue(issue) + const newHtml = processHtml(html, currentBgColors) + if (activeIssue.newHtml !== newHtml) { + const issue = { ...activeIssue, newHtml } + handleActiveIssue(issue) + } } + // Handlers const updateText = (event) => { const value = event.target.value - if(!isValidHexColor(value)) { - return - } - setTextColor(value) + if (isValidHexColor(value)) setTextColor(value) } - const updateBackground = (event) => { - const value = event.target.value - if(!isValidHexColor(value)) { - return - } - setBackgroundColor(value) - } + // On issue change, extract from original HTML + useEffect(() => { + console.log(activeIssue) + const info = getBackgroundColors() // Use sourceHtml inside this function if available + setOriginalBgColors(info) + setCurrentBgColors(info.map(bg => bg.standardColor)) + setTextColor(getTextColor()) + // eslint-disable-next-line + }, [activeIssue]) - const handleLightenText = () => { - const newColor = Contrast.changehue(textColor, 'lighten') - setTextColor(newColor) + // On user interaction, only update state (do NOT call getBackgroundColors again) + const updateBackgroundColor = (idx, value) => { + setCurrentBgColors(colors => + colors.map((c, i) => i === idx ? value : c) + ) } - const handleDarkenText = () => { - const newColor = Contrast.changehue(textColor, 'darken') - setTextColor(newColor) - } + const handleLightenText = () => setTextColor(Contrast.changehue(textColor, 'lighten')) + const handleDarkenText = () => setTextColor(Contrast.changehue(textColor, 'darken')) - const handleLightenBackground = () => { - const newColor = Contrast.changehue(backgroundColor, 'lighten') - setBackgroundColor(newColor) + const handleLightenBackground = idx => { + setCurrentBgColors(colors => + colors.map((c, i) => i === idx ? Contrast.changehue(c, 'lighten') : c) + ) } - - const handleDarkenBackground = () => { - const newColor = Contrast.changehue(backgroundColor, 'darken') - setBackgroundColor(newColor) + const handleDarkenBackground = idx => { + setCurrentBgColors(colors => + colors.map((c, i) => i === idx ? Contrast.changehue(c, 'darken') : c) + ) } const handleSubmit = () => { - let issue = activeIssue - if(ratioIsValid || markAsReviewed) { - issue.newHtml = Contrast.convertHtmlRgb2Hex(issue.newHtml) + if (ratioIsValid || markAsReviewed) { + const issue = { ...activeIssue, newHtml: Contrast.convertHtmlRgb2Hex(activeIssue.newHtml) } handleIssueSave(issue) } } - useEffect(() => { - updatePreview() - }, [textColor, backgroundColor]) + // Debounce timer ref + const debounceTimer = useRef(null) + // Debounced updatePreview useEffect(() => { - setBackgroundColor(getBackgroundColor()) - setTextColor(getTextColor()) - }, [activeIssue]) + if (debounceTimer.current) clearTimeout(debounceTimer.current) + debounceTimer.current = setTimeout(() => { + updatePreview() + }, 150) // 150ms debounce, adjust as needed + return () => clearTimeout(debounceTimer.current) + // eslint-disable-next-line + }, [textColor, currentBgColors]) + // Render return ( <>
{t('form.contrast.label.adjust')}
-
@@ -161,7 +209,8 @@ export default function ContrastForm({ tabIndex="0" disabled={isDisabled} value={textColor} - onChange={updateText} /> + onChange={updateText} + />
@@ -170,7 +219,7 @@ export default function ContrastForm({ tabIndex="0" disabled={isDisabled} onClick={handleLightenText}> - + {t('form.contrast.label.lighten')}
@@ -180,7 +229,7 @@ export default function ContrastForm({ tabIndex="0" disabled={isDisabled} onClick={handleDarkenText}> - + {t('form.contrast.label.darken')}
@@ -188,43 +237,42 @@ export default function ContrastForm({
- +
-
-
+ {currentBgColors.map((color, idx) => ( +
-
-
-
+ value={color} + style={{ width: '2.5em', height: '2em' }} + onChange={e => updateBackgroundColor(idx, e.target.value)} + /> +
-
-
-
+ ))}
@@ -241,28 +289,41 @@ export default function ContrastForm({ )}
-
{contrastRatio}
+
{contrastRatio}
-
{ratioIsValid ? t('form.contrast.feedback.valid') : t('form.contrast.feedback.invalid')}
+
+ {ratioIsValid ? t('form.contrast.feedback.valid') : t('form.contrast.feedback.invalid')} +
+ {currentBgColors.length > 1 && ( +
+ + * lowest among gradient colors + +
+ )}
- { (activeIssue.status === 1 || activeIssue.status === 3) ? ( -
- -
{t('filter.label.resolution.fixed_single')}
+ {(activeIssue.status === 1 || activeIssue.status === 3) ? ( +
+ +
+ {t('filter.label.resolution.fixed_single')}
- ) : activeIssue.status === 2 ? ( -
- -
{t('filter.label.resolution.resolved_single')}
+
+ ) : activeIssue.status === 2 ? ( +
+ +
+ {t('filter.label.resolution.resolved_single')}
- ) : ''} +
+ ) : ''}
))} + {currentBgColors.length > 1 && ( +
+ +
+ )} +
diff --git a/translations/en.json b/translations/en.json index 22ffa56a6..7a486ca36 100644 --- a/translations/en.json +++ b/translations/en.json @@ -338,6 +338,7 @@ "form.contrast.replace_text": "Text Color", "form.contrast.replace_background": "Background Color", "form.contrast.label.ratio": "Contrast Ratio", + "form.contrast.label.auto_adjust_all": "Auto-Adjust All Colors", "form.contrast.feedback.invalid": "Invalid Ratio", "form.contrast.feedback.valid": "Valid Ratio", "rule.desc.text_contrast_sufficient": "", diff --git a/translations/es.json b/translations/es.json index f5d1f1aad..908ed38c9 100644 --- a/translations/es.json +++ b/translations/es.json @@ -339,6 +339,7 @@ "form.contrast.feedback.invalid": "Relación Inválida", "form.contrast.feedback.valid": "Relación Válida", "form.contrast.label.bolden_text": "Poner Texto en Negrita", + "form.contrast.label.auto_adjust": "Ajustar Colores Automáticamente", "form.contrast.label.italicize_text": "Poner Texto en Cursiva", "rule.desc.text_contrast_sufficient": "", From 58109686b82db0076bd6b293c6835aeda2f9f490 Mon Sep 17 00:00:00 2001 From: Elias Wainberg Date: Thu, 16 Oct 2025 11:34:48 -0400 Subject: [PATCH 05/14] Fixes background color select sizing and spacing --- assets/css/udoit4-theme.css | 9 ++++++++ assets/js/Components/Forms/ContrastForm.js | 25 +++++++++++----------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/assets/css/udoit4-theme.css b/assets/css/udoit4-theme.css index d3ff6d2a1..408b29c14 100644 --- a/assets/css/udoit4-theme.css +++ b/assets/css/udoit4-theme.css @@ -828,6 +828,15 @@ input[type="color"] { } } +.gradient-row { + margin-bottom: 0.75em; +} + +.gradient-note { + font-style: italic; + color: #666; +} + .icon-sm { width: 16px; height: 16px; diff --git a/assets/js/Components/Forms/ContrastForm.js b/assets/js/Components/Forms/ContrastForm.js index d1b392115..f4e5b7b81 100644 --- a/assets/js/Components/Forms/ContrastForm.js +++ b/assets/js/Components/Forms/ContrastForm.js @@ -299,17 +299,18 @@ export default function ContrastForm({
{currentBgColors.map((color, idx) => ( -
- updateBackgroundColor(idx, e.target.value)} - /> +
+
+ updateBackgroundColor(idx, e.target.value)} + /> +
- + {currentBgColors.map((color, idx) => { + let showStatus = currentBgColors.length > 1; + let tagName = Html.toElement(Html.getIssueHtml(activeIssue)).tagName; + let minRatio = headingTags.includes(tagName) ? 3 : 4.5; + let ratio = Contrast.contrastRatio(color, textColor); + let isValid = ratio >= minRatio; + + return ( +
+
+ updateBackgroundColor(idx, e.target.value)} + /> + {showStatus && ( + isValid + ? + : + )} +
+
+ + +
-
- ))} + ); + })} {currentBgColors.length > 1 && (
diff --git a/translations/en.json b/translations/en.json index 7a486ca36..a66f71561 100644 --- a/translations/en.json +++ b/translations/en.json @@ -339,8 +339,8 @@ "form.contrast.replace_background": "Background Color", "form.contrast.label.ratio": "Contrast Ratio", "form.contrast.label.auto_adjust_all": "Auto-Adjust All Colors", - "form.contrast.feedback.invalid": "Invalid Ratio", - "form.contrast.feedback.valid": "Valid Ratio", + "form.contrast.feedback.invalid": "Insufficient Contrast", + "form.contrast.feedback.valid": "Sufficient Contrast", "rule.desc.text_contrast_sufficient": "", "form.embedded_content_title.title": "Embedded Content Should Have a Label", diff --git a/translations/es.json b/translations/es.json index 908ed38c9..93d1265e0 100644 --- a/translations/es.json +++ b/translations/es.json @@ -336,8 +336,8 @@ "form.contrast.replace_text": "Color del Texto", "form.contrast.replace_background": "Color de Fondo", "form.contrast.label.ratio": "Relación de Contraste", - "form.contrast.feedback.invalid": "Relación Inválida", - "form.contrast.feedback.valid": "Relación Válida", + "form.contrast.feedback.invalid": "Contraste Insuficiente", + "form.contrast.feedback.valid": "Contraste Suficiente", "form.contrast.label.bolden_text": "Poner Texto en Negrita", "form.contrast.label.auto_adjust": "Ajustar Colores Automáticamente", "form.contrast.label.italicize_text": "Poner Texto en Cursiva", From 90241efc39678e23f0811a8d86ff03f4e47a84d7 Mon Sep 17 00:00:00 2001 From: Elias Wainberg Date: Tue, 28 Oct 2025 14:10:43 -0400 Subject: [PATCH 07/14] Adds fixes for: - Red dotted focus line will now draw around the text relevant to the issue as well as the background - Fixes outdated logic for getTextColor, now supports new framework with textColorXpath NOTE: triggerLiveUpdate() will break the dotted focus box for reasons unknown, will be fixed later somewhere else --- assets/js/Components/Forms/ContrastForm.js | 23 +++++++------------ .../Widgets/FixIssuesContentPreview.js | 11 +++++++++ assets/js/Components/Widgets/UfixitWidget.js | 2 +- assets/js/Services/Html.js | 3 +++ assets/js/Services/Report.js | 2 +- 5 files changed, 24 insertions(+), 17 deletions(-) diff --git a/assets/js/Components/Forms/ContrastForm.js b/assets/js/Components/Forms/ContrastForm.js index 7dec32eff..610a2223d 100644 --- a/assets/js/Components/Forms/ContrastForm.js +++ b/assets/js/Components/Forms/ContrastForm.js @@ -75,23 +75,16 @@ export default function ContrastForm({ const html = Html.getIssueHtml(activeIssue); const element = Html.toElement(html); - // Helper to find first descendant with a color style - function findTextColorEl(el) { - if (!el) return null; - if (el.style && el.style.color && el.style.color !== '') return el; - for (let i = 0; i < el.children.length; i++) { - const found = findTextColorEl(el.children[i]); - if (found) return found; - } - return null; + let colorEl = element; + if (metadata.textColorXpath && Html.findElementWithXpath) { + const found = Html.findElementWithXpath(element, metadata.textColorXpath); + if (found) colorEl = found; } - const colorEl = findTextColorEl(element); - - if (colorEl && colorEl.style.color) { + if (colorEl && colorEl.style && colorEl.style.color) { return Contrast.standardizeColor(colorEl.style.color); } - return metadata.color ? Contrast.standardizeColor(metadata.color) : settings.textColor + return settings.textColor; } // Heading tags for contrast threshold @@ -159,8 +152,8 @@ export default function ContrastForm({ const newHtml = processHtml(html, currentBgColors) if (activeIssue.newHtml !== newHtml) { - const issue = { ...activeIssue, newHtml } - handleActiveIssue(issue) + activeIssue.newHtml = newHtml + handleActiveIssue(activeIssue) } } diff --git a/assets/js/Components/Widgets/FixIssuesContentPreview.js b/assets/js/Components/Widgets/FixIssuesContentPreview.js index 9e7230319..7ce4b4673 100644 --- a/assets/js/Components/Widgets/FixIssuesContentPreview.js +++ b/assets/js/Components/Widgets/FixIssuesContentPreview.js @@ -74,6 +74,10 @@ export default function FixIssuesContentPreview({ formNames.HEADING_STYLE ] + const FOCUS_RELATED = [ + formNames.CONTRAST + ] + const convertErrorHtmlString = (htmlText) => { let tempElement = Html.toElement(htmlText) if(tempElement){ @@ -190,6 +194,13 @@ export default function FixIssuesContentPreview({ } else { errorElement.replaceWith(convertErrorHtmlElement(errorElement)) } + if (FOCUS_RELATED.includes(formNameFromRule(activeIssue.scanRuleId))) { + let metadata = JSON.parse(activeIssue.issueData.metadata) + let focusElement = Html.findElementWithXpath(doc, metadata.focusXpath) + if (focusElement) { + focusElement.replaceWith(convertErrorHtmlElement(focusElement)) + } + } setCanShowPreview(true) setIsErrorFoundInContent(true) } diff --git a/assets/js/Components/Widgets/UfixitWidget.js b/assets/js/Components/Widgets/UfixitWidget.js index 3a5365121..3635b11e6 100644 --- a/assets/js/Components/Widgets/UfixitWidget.js +++ b/assets/js/Components/Widgets/UfixitWidget.js @@ -123,7 +123,7 @@ export default function UfixitWidget({ tempIssue.issueData = newIssue tempIssue.isModified = newIssue?.isModified || false setTempActiveIssue(tempIssue) - triggerLiveUpdate() + //triggerLiveUpdate() } const interceptIssueSave = (issue) => { diff --git a/assets/js/Services/Html.js b/assets/js/Services/Html.js index 57f03d264..94c4ef1df 100644 --- a/assets/js/Services/Html.js +++ b/assets/js/Services/Html.js @@ -428,6 +428,9 @@ export const findXpathFromElement = (element) => { } export const findElementWithXpath = (content, xpath) => { + if (!content || !xpath) { + return null + } if(xpath.startsWith('/html[1]/body[1]/main[1]/')) { xpath = xpath.replace('/html[1]/body[1]/main[1]/', '/html[1]/body[1]/') diff --git a/assets/js/Services/Report.js b/assets/js/Services/Report.js index 2df70e8ad..a4bb69364 100644 --- a/assets/js/Services/Report.js +++ b/assets/js/Services/Report.js @@ -98,7 +98,6 @@ const checkTextContrastSufficient = (issue, element, parsedDocument) => { if (bgAncestor) { issue.xpath = Html.findXpathFromElement(bgAncestor); issue.sourceHtml = Html.toString(bgAncestor); // <-- Set sourceHtml to the correct scope - } else { } // Store the text color element's xpath in metadata @@ -128,6 +127,7 @@ const checkTextContrastSufficient = (issue, element, parsedDocument) => { } issue.metadata = issue.metadata || {}; issue.metadata.textColorXpath = getRelativeXpath(bgAncestor, textAncestor); + issue.metadata.focusXpath = Html.findXpathFromElement(element); issue.metadata = JSON.stringify(issue.metadata); } // Return false to indicate this issue should not be ignored From dafdc95eaf432b2cd5f1001e255967dcada61034 Mon Sep 17 00:00:00 2001 From: Elias Wainberg Date: Thu, 30 Oct 2025 10:32:26 -0400 Subject: [PATCH 08/14] Adds auto-adjust colors error handling and some translations --- assets/js/Components/Forms/ContrastForm.js | 33 ++++++++++++++++------ translations/en.json | 2 ++ translations/es.json | 2 ++ 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/assets/js/Components/Forms/ContrastForm.js b/assets/js/Components/Forms/ContrastForm.js index 610a2223d..521c116de 100644 --- a/assets/js/Components/Forms/ContrastForm.js +++ b/assets/js/Components/Forms/ContrastForm.js @@ -96,6 +96,7 @@ export default function ContrastForm({ const [textColor, setTextColor] = useState('') const [contrastRatio, setContrastRatio] = useState(null) const [ratioIsValid, setRatioIsValid] = useState(false) + const [autoAdjustError, setAutoAdjustError] = useState(false) // Validate hex color const isValidHexColor = (color) => /^#?([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/.test(color) @@ -165,10 +166,11 @@ export default function ContrastForm({ // On issue change, extract from original HTML useEffect(() => { - const info = getBackgroundColors() // Use sourceHtml inside this function if available + const info = getBackgroundColors() setOriginalBgColors(info) setCurrentBgColors(info.map(bg => bg.standardColor)) setTextColor(getTextColor()) + setAutoAdjustError(false) // Reset error when switching issues // eslint-disable-next-line }, [activeIssue]) @@ -214,19 +216,16 @@ export default function ContrastForm({ }, [textColor, currentBgColors]) const handleAutoAdjustAll = () => { - // Try to minimally adjust each background color to achieve valid contrast let newBgColors = [...currentBgColors]; let changed = false; + let failed = false; for (let i = 0; i < newBgColors.length; i++) { let bg = newBgColors[i]; let ratio = Contrast.contrastRatio(bg, textColor); - // Use the same threshold logic as your UI const tagName = Html.toElement(Html.getIssueHtml(activeIssue)).tagName; const minRatio = headingTags.includes(tagName) ? 3 : 4.5; let attempts = 0; - // Try to lighten or darken until valid, but don't go infinite while (ratio < minRatio && attempts < 20) { - // Decide direction: lighten or darken based on which is closer to valid const lighter = Contrast.changehue(bg, 'lighten'); const darker = Contrast.changehue(bg, 'darken'); const lighterRatio = Contrast.contrastRatio(lighter, textColor); @@ -240,10 +239,20 @@ export default function ContrastForm({ } attempts++; } + if (ratio < minRatio) { + failed = true; + break; // No need to continue if one fails + } if (attempts > 0) changed = true; newBgColors[i] = bg; } - if (changed) setCurrentBgColors(newBgColors); + if (failed) { + setCurrentBgColors(originalBgColors.map(bg => bg.standardColor)); + setAutoAdjustError(true); + } else if (changed) { + setCurrentBgColors(newBgColors); + setAutoAdjustError(false); + } }; // Render @@ -359,11 +368,19 @@ export default function ContrastForm({ disabled={isDisabled} onClick={handleAutoAdjustAll} > - {t('form.contrast.label.auto_adjust_all') || 'Auto Adjust All'} + {t('form.contrast.label.auto_adjust_all')}
)} + {autoAdjustError && ( +
+
+ {t('form.contrast.auto_adjust_error')} +
+
+ )} +
@@ -390,7 +407,7 @@ export default function ContrastForm({ {currentBgColors.length > 1 && (
- * lowest among gradient colors + {t('form.contrast.ratio_note')}
)} diff --git a/translations/en.json b/translations/en.json index a66f71561..717aa497d 100644 --- a/translations/en.json +++ b/translations/en.json @@ -341,6 +341,8 @@ "form.contrast.label.auto_adjust_all": "Auto-Adjust All Colors", "form.contrast.feedback.invalid": "Insufficient Contrast", "form.contrast.feedback.valid": "Sufficient Contrast", + "form.contrast.auto_adjust_error": "We couldn't auto-adjust your colors, please do so manually.", + "form.contrast.ratio_note": "* lowest among gradient colors", "rule.desc.text_contrast_sufficient": "", "form.embedded_content_title.title": "Embedded Content Should Have a Label", diff --git a/translations/es.json b/translations/es.json index 93d1265e0..a535224e4 100644 --- a/translations/es.json +++ b/translations/es.json @@ -341,6 +341,8 @@ "form.contrast.label.bolden_text": "Poner Texto en Negrita", "form.contrast.label.auto_adjust": "Ajustar Colores Automáticamente", "form.contrast.label.italicize_text": "Poner Texto en Cursiva", + "form.contrast.auto_adjust_error": "No se pudo ajustar automáticamente el color. Intenta ajustar manualmente los colores.", + "form.contrast.ratio_note": "* más bajo entre todos los colores", "rule.desc.text_contrast_sufficient": "", "form.embedded_content_title.title": "El Contenido Incrustado Debe Tener una Etiqueta", From b8ae6058fd12579a4cc8c75211e27dcfd83d7c4e Mon Sep 17 00:00:00 2001 From: Elias Wainberg Date: Tue, 4 Nov 2025 11:24:56 -0500 Subject: [PATCH 09/14] Changes color standard to HSL to prevent color data loss --- assets/js/Components/Forms/ContrastForm.js | 65 +++--- assets/js/Services/Contrast.js | 254 +++------------------ package.json | 1 + 3 files changed, 62 insertions(+), 258 deletions(-) diff --git a/assets/js/Components/Forms/ContrastForm.js b/assets/js/Components/Forms/ContrastForm.js index 521c116de..a7ce135a2 100644 --- a/assets/js/Components/Forms/ContrastForm.js +++ b/assets/js/Components/Forms/ContrastForm.js @@ -48,22 +48,21 @@ export default function ContrastForm({ const styleValue = bgMatch[2] const colors = extractColors(styleValue) colors.forEach(color => { - const standardColor = Contrast.standardizeColor(color) - if (standardColor) { + const hsl = Contrast.toHSL(color) + if (hsl) { tempBackgroundColors.push({ originalString: styleValue, originalColorString: color, - standardColor + hsl }) } }) } - if (tempBackgroundColors.length === 0) { tempBackgroundColors.push({ originalString: '', originalColorString: settings.backgroundColor, - standardColor: settings.backgroundColor + hsl: Contrast.toHSL(settings.backgroundColor) }) } return tempBackgroundColors @@ -82,9 +81,9 @@ export default function ContrastForm({ } if (colorEl && colorEl.style && colorEl.style.color) { - return Contrast.standardizeColor(colorEl.style.color); + return Contrast.toHSL(colorEl.style.color); } - return settings.textColor; + return Contrast.toHSL(settings.textColor); } // Heading tags for contrast threshold @@ -93,7 +92,7 @@ export default function ContrastForm({ // State const [originalBgColors, setOriginalBgColors] = useState([]) const [currentBgColors, setCurrentBgColors] = useState([]) - const [textColor, setTextColor] = useState('') + const [textColor, setTextColor] = useState(null) const [contrastRatio, setContrastRatio] = useState(null) const [ratioIsValid, setRatioIsValid] = useState(false) const [autoAdjustError, setAutoAdjustError] = useState(false) @@ -104,17 +103,15 @@ export default function ContrastForm({ // Generate updated HTML with new colors const processHtml = (html, bgColors) => { let element = Html.toElement(html); - - // Set background as before if (bgColors.length > 1) { let gradientHtml = originalBgColors[0].originalString; originalBgColors.forEach((bg, idx) => { - gradientHtml = gradientHtml.replace(bg.originalColorString, bgColors[idx]); + gradientHtml = gradientHtml.replace(bg.originalColorString, Contrast.hslToHex(bgColors[idx])); }); element.style.background = gradientHtml; element.style.backgroundColor = ''; } else if (bgColors.length === 1) { - element.style.backgroundColor = Contrast.convertShortenedHex(bgColors[0]); + element.style.backgroundColor = Contrast.hslToHex(bgColors[0]); } else { element.style.background = ''; } @@ -133,7 +130,7 @@ export default function ContrastForm({ if (found) textEl = found; } } catch (e) {} - textEl.style.color = Contrast.convertShortenedHex(textColor); + textEl.style.color = Contrast.hslToHex(textColor); return Html.toString(element) } @@ -143,7 +140,9 @@ export default function ContrastForm({ const html = Html.getIssueHtml(activeIssue) let ratio = 1 if (currentBgColors.length > 0 && textColor) { - const ratios = currentBgColors.map(bg => Contrast.contrastRatio(bg, textColor)) + const ratios = currentBgColors.map(bg => Contrast.contrastRatio( + Contrast.hslToHex(bg), Contrast.hslToHex(textColor) + )) ratio = Math.min(...ratios) } const tagName = Html.toElement(html).tagName @@ -161,14 +160,15 @@ export default function ContrastForm({ // Handlers const updateText = (event) => { const value = event.target.value - if (isValidHexColor(value)) setTextColor(value) + const hsl = Contrast.toHSL(value) + if (hsl) setTextColor(hsl) } // On issue change, extract from original HTML useEffect(() => { const info = getBackgroundColors() setOriginalBgColors(info) - setCurrentBgColors(info.map(bg => bg.standardColor)) + setCurrentBgColors(info.map(bg => bg.hsl)) setTextColor(getTextColor()) setAutoAdjustError(false) // Reset error when switching issues // eslint-disable-next-line @@ -176,22 +176,23 @@ export default function ContrastForm({ // On user interaction, only update state (do NOT call getBackgroundColors again) const updateBackgroundColor = (idx, value) => { + const hsl = Contrast.toHSL(value) setCurrentBgColors(colors => - colors.map((c, i) => i === idx ? value : c) + colors.map((c, i) => i === idx ? hsl : c) ) } - const handleLightenText = () => setTextColor(Contrast.changehue(textColor, 'lighten')) - const handleDarkenText = () => setTextColor(Contrast.changehue(textColor, 'darken')) + const handleLightenText = () => setTextColor(Contrast.changeLuminance(textColor, 'lighten')) + const handleDarkenText = () => setTextColor(Contrast.changeLuminance(textColor, 'darken')) const handleLightenBackground = idx => { setCurrentBgColors(colors => - colors.map((c, i) => i === idx ? Contrast.changehue(c, 'lighten') : c) + colors.map((c, i) => i === idx ? Contrast.changeLuminance(c, 'lighten') : c) ) } const handleDarkenBackground = idx => { setCurrentBgColors(colors => - colors.map((c, i) => i === idx ? Contrast.changehue(c, 'darken') : c) + colors.map((c, i) => i === idx ? Contrast.changeLuminance(c, 'darken') : c) ) } @@ -226,8 +227,8 @@ export default function ContrastForm({ const minRatio = headingTags.includes(tagName) ? 3 : 4.5; let attempts = 0; while (ratio < minRatio && attempts < 20) { - const lighter = Contrast.changehue(bg, 'lighten'); - const darker = Contrast.changehue(bg, 'darken'); + const lighter = Contrast.changeLuminance(bg, 'lighten'); + const darker = Contrast.changeLuminance(bg, 'darken'); const lighterRatio = Contrast.contrastRatio(lighter, textColor); const darkerRatio = Contrast.contrastRatio(darker, textColor); if (lighterRatio > darkerRatio) { @@ -247,7 +248,7 @@ export default function ContrastForm({ newBgColors[i] = bg; } if (failed) { - setCurrentBgColors(originalBgColors.map(bg => bg.standardColor)); + setCurrentBgColors(originalBgColors.map(bg => bg.hsl)); setAutoAdjustError(true); } else if (changed) { setCurrentBgColors(newBgColors); @@ -266,13 +267,13 @@ export default function ContrastForm({
@@ -306,7 +307,9 @@ export default function ContrastForm({ let showStatus = currentBgColors.length > 1; let tagName = Html.toElement(Html.getIssueHtml(activeIssue)).tagName; let minRatio = headingTags.includes(tagName) ? 3 : 4.5; - let ratio = Contrast.contrastRatio(color, textColor); + let ratio = Contrast.contrastRatio( + Contrast.hslToHex(color), Contrast.hslToHex(textColor) + ); let isValid = ratio >= minRatio; return ( @@ -314,12 +317,12 @@ export default function ContrastForm({
updateBackgroundColor(idx, e.target.value)} aria-label={t('form.contrast.label.background.show_color_picker')} title={t('form.contrast.label.background.show_color_picker')} - type="color" disabled={isDisabled} - value={color} - onChange={e => updateBackgroundColor(idx, e.target.value)} /> {showStatus && ( isValid diff --git a/assets/js/Services/Contrast.js b/assets/js/Services/Contrast.js index 2bfd236a3..e94eb0608 100644 --- a/assets/js/Services/Contrast.js +++ b/assets/js/Services/Contrast.js @@ -1,242 +1,42 @@ -export function rgb2hex(rgb) { - if (/^#[0-9A-F]{6}$/i.test(rgb)) return rgb; +import chroma from "chroma-js"; - rgb = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/); - - if (!rgb) { - return rgb; - } - - function hex(x) { - return ("0" + parseInt(x).toString(16)).slice(-2); - } - return "#" + hex(rgb[1]) + hex(rgb[2]) + hex(rgb[3]); -} - -export function hexToRgb(hex) { - // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") - const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; - hex = hex.replace(shorthandRegex, (m, r, g, b) => { - return r + r + g + g + b + b; - }); - - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - return result ? { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16) - } : null; -} - -export function changehue(hex, dir) { - const color = hex.substring(1) - let update - let R, G, B - - if (color.length == 3) { - R = color.substring(0, 1) + color.substring(0, 1); - G = color.substring(1, 2) + color.substring(1, 2); - B = color.substring(2, 3) + color.substring(2, 3); - update = true; - } - else if (color.length == 6) { - R = color.substring(0, 2); - G = color.substring(2, 4); - B = color.substring(4, 6); - update = true; - } - else { - return '#' + color - } - R = getRGB(R); - G = getRGB(G); - B = getRGB(B); - - const HSL = RGBtoHSL(R, G, B); - let lightness = HSL[2]; - if (update == true) { - lightness = (dir == "lighten") ? lightness + 6.25 : lightness - 6.25; - if (lightness > 100) { - lightness = 100; - } - if (lightness < 0) { - lightness = 0; - } - const RGB = hslToRgb(HSL[0], HSL[1], lightness); - R = RGB[0]; - G = RGB[1]; - B = RGB[2]; - if (!(R >= 0) && !(R <= 255)) R = 0 - if (!(G >= 0) && !(G <= 255)) G = 0 - if (!(B >= 0) && !(B <= 255)) B = 0 - R = (R >= 16) ? R.toString(16) : "0" + R.toString(16); - G = (G >= 16) ? G.toString(16) : "0" + G.toString(16); - B = (B >= 16) ? B.toString(16) : "0" + B.toString(16); - R = (R.length == 1) ? R + R : R; - G = (G.length == 1) ? G + G : G; - B = (B.length == 1) ? B + B : B; - return ('#' + R + G + B); - } -} - -export function RGBtoHSL(r, g, b) { - let Min = 0; - let Max = 0; - let H, S, L - r = (parseInt(r) / 51) * .2; - g = (parseInt(g) / 51) * .2; - b = (parseInt(b) / 51) * .2; - - if (r >= g) - Max = r; - else - Max = g; - if (b > Max) - Max = b; - - if (r <= g) - Min = r; - else - Min = g; - if (b < Min) - Min = b; - - L = (Max + Min) / 2; - if (Max == Min) { - S = 0; - H = 0; - } - else { - if (L < .5) - S = (Max - Min) / (Max + Min); - if (L >= .5) - S = (Max - Min) / (2 - Max - Min); - if (r == Max) - H = (g - b) / (Max - Min); - if (g == Max) - H = 2 + ((b - r) / (Max - Min)); - if (b == Max) - H = 4 + ((r - g) / (Max - Min)); - } - H = Math.round(H * 60); - if (H < 0) H += 360; - if (H >= 360) H -= 360; - S = Math.round(S * 100); - L = Math.round(L * 100); - return [H, S, L]; -} - -export function hslToRgb(H, S, L) { - let p1, p2; - let R, G, B; - L /= 100; - S /= 100; - if (L <= 0.5) p2 = L * (1 + S); - else p2 = L + S - (L * S); - p1 = 2 * L - p2; - if (S == 0) { - R = L; - G = L; - B = L; - } - else { - R = FindRGB(p1, p2, H + 120); - G = FindRGB(p1, p2, H); - B = FindRGB(p1, p2, H - 120); +export function toHSL(color) { + try { + const [h, s, l] = chroma(color).hsl(); + return { h, s, l }; + } catch { + console.error('Error converting color to HSL:', color); + return { h: 0, s: 0, l: 0 }; } - R *= 255; - G *= 255; - B *= 255; - R = Math.round(R); - G = Math.round(G); - B = Math.round(B); - - return [R, G, B]; } -export function getRGB(color) { +export function hslToHex(hsl) { try { - color = parseInt(color, 16); + return chroma.hsl(hsl.h, hsl.s, hsl.l).hex(); + } catch { + console.error('Error converting HSL to hex:', hsl); + return null; } - catch (err) { - color = false; - } - return color; } -export function FindRGB(q1, q2, hue) { - if (hue > 360) hue = hue - 360; - if (hue < 0) hue = hue + 360; - if (hue < 60) return (q1 + (q2 - q1) * hue / 60); - else if (hue < 180) return (q2); - else if (hue < 240) return (q1 + (q2 - q1) * (240 - hue) / 60); - else return (q1); -} - -export function contrastRatio(back, fore) { - const l1 = relativeLuminance(parseRgb(back)); - const l2 = relativeLuminance(parseRgb(fore)); - let ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); - - ratio = Math.round((ratio * 100)) / 100; - - return ratio || 1; -} - -export function relativeLuminance(c) { - const lum = []; - for (let i = 0; i < 3; i++) { - const v = c[i] / 255; - lum.push(v < 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)); +export function changeLuminance(hsl, dir) { + if (!hsl || typeof hsl.l !== 'number') return hsl; + const step = 0.05; + let newL = hsl.l; + if (dir === "lighten") { + newL = Math.min(1, newL + step); + } else { + newL = Math.max(0, newL - step); } - return 0.2126 * lum[0] + 0.7152 * lum[1] + 0.0722 * lum[2]; + return { h: hsl.h, s: hsl.s, l: newL }; } -export function parseRgb(color) { - color = convertShortenedHex(color) - color = color.substring(1); - - const hex = parseInt(color.toUpperCase(), 16); - - const r = hex >> 16; - const g = hex >> 8 & 0xFF; - const b = hex & 0xFF; - return [r, g, b]; -} - -export function convertShortenedHex(color) { - color = color.substring(1); - - // If the string is too long, cut it off at 6 characters - if(color.length > 6) { - color = color.substring(0,6) - } - // If the length is 3, hex shorthand is being used - else if (color.length == 3) { - color = color[0] + color[0] + color[1] + color[1] + color[2] + color[2]; - } - // If the length is not 3 or 6, pad the end with zeroes - else if(color.length != 6) { - let padding = '0'.repeat(6 - color.length) - color = color + padding; - } - - return '#' + color -} - -// Accepts HSL, RGB, Hex, color name (eg. red, black) and returns the hex value -export function standardizeColor(color){ - const element = document.createElement("canvas").getContext("2d") - element.fillStyle = 'rgba(0, 0, 0, 0)' // This translates 100% transparent - - // If the color is invalid, the fillStyle will not change - element.fillStyle = color - - if(element.fillStyle === 'rgba(0, 0, 0, 0)') { - return null +export function contrastRatio(back, fore) { + try { + return Math.round(chroma.contrast(back, fore) * 100) / 100; + } catch { + return 1; } - - return element.fillStyle } export function convertHtmlRgb2Hex(html) { diff --git a/package.json b/package.json index e446208c8..4db650585 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "dependencies": { "axios": "1.10.0", "chart.js": "^4.5.0", + "chroma-js": "^3.1.2", "react": "^19.1.0", "react-dom": "^19.1.0", "tinymce": "^7.9.1", From 726347f98b4861badcd3140e6772166d7bb4962a Mon Sep 17 00:00:00 2001 From: Elias Wainberg Date: Tue, 4 Nov 2025 11:43:33 -0500 Subject: [PATCH 10/14] Adds font size and weight to ratio valid logic --- assets/js/Components/Forms/ContrastForm.js | 38 +++++++++++++--------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/assets/js/Components/Forms/ContrastForm.js b/assets/js/Components/Forms/ContrastForm.js index a7ce135a2..8b37d52c8 100644 --- a/assets/js/Components/Forms/ContrastForm.js +++ b/assets/js/Components/Forms/ContrastForm.js @@ -97,9 +97,6 @@ export default function ContrastForm({ const [ratioIsValid, setRatioIsValid] = useState(false) const [autoAdjustError, setAutoAdjustError] = useState(false) - // Validate hex color - const isValidHexColor = (color) => /^#?([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/.test(color) - // Generate updated HTML with new colors const processHtml = (html, bgColors) => { let element = Html.toElement(html); @@ -145,11 +142,11 @@ export default function ContrastForm({ )) ratio = Math.min(...ratios) } - const tagName = Html.toElement(html).tagName - const valid = headingTags.includes(tagName) ? (ratio >= 3) : (ratio >= 4.5) + const element = Html.toElement(html); + const minRatio = isLargeText(element) ? 3 : 4.5; setContrastRatio(ratio) - setRatioIsValid(valid) - + setRatioIsValid(ratio >= minRatio) + const newHtml = processHtml(html, currentBgColors) if (activeIssue.newHtml !== newHtml) { activeIssue.newHtml = newHtml @@ -170,11 +167,10 @@ export default function ContrastForm({ setOriginalBgColors(info) setCurrentBgColors(info.map(bg => bg.hsl)) setTextColor(getTextColor()) - setAutoAdjustError(false) // Reset error when switching issues + setAutoAdjustError(false) // eslint-disable-next-line }, [activeIssue]) - // On user interaction, only update state (do NOT call getBackgroundColors again) const updateBackgroundColor = (idx, value) => { const hsl = Contrast.toHSL(value) setCurrentBgColors(colors => @@ -203,15 +199,13 @@ export default function ContrastForm({ } } - // Debounce timer ref const debounceTimer = useRef(null) - // Debounced updatePreview useEffect(() => { if (debounceTimer.current) clearTimeout(debounceTimer.current) debounceTimer.current = setTimeout(() => { updatePreview() - }, 150) // 150ms debounce, adjust as needed + }, 150) return () => clearTimeout(debounceTimer.current) // eslint-disable-next-line }, [textColor, currentBgColors]) @@ -220,11 +214,11 @@ export default function ContrastForm({ let newBgColors = [...currentBgColors]; let changed = false; let failed = false; + const element = Html.toElement(Html.getIssueHtml(activeIssue)); + const minRatio = isLargeText(element) ? 3 : 4.5; for (let i = 0; i < newBgColors.length; i++) { let bg = newBgColors[i]; let ratio = Contrast.contrastRatio(bg, textColor); - const tagName = Html.toElement(Html.getIssueHtml(activeIssue)).tagName; - const minRatio = headingTags.includes(tagName) ? 3 : 4.5; let attempts = 0; while (ratio < minRatio && attempts < 20) { const lighter = Contrast.changeLuminance(bg, 'lighten'); @@ -242,7 +236,7 @@ export default function ContrastForm({ } if (ratio < minRatio) { failed = true; - break; // No need to continue if one fails + break; } if (attempts > 0) changed = true; newBgColors[i] = bg; @@ -256,6 +250,20 @@ export default function ContrastForm({ } }; + function isLargeText(element) { + if (!element) return false; + const style = window.getComputedStyle(element); + const fontSizePx = parseFloat(style.fontSize); + const fontWeight = style.fontWeight; + + // Convert px to pt (1pt = 1.333px) + const fontSizePt = fontSizePx / 1.333; + + // WCAG: large text is >= 18pt (24px) regular or >= 14pt (18.67px) bold + const isBold = parseInt(fontWeight, 10) >= 700 || style.fontWeight === 'bold'; + return (fontSizePt >= 18) || (isBold && fontSizePt >= 14); + } + // Render return ( <> From 5a4f0136224f071ae458df5a2f743c2c39cbe306 Mon Sep 17 00:00:00 2001 From: Elias Wainberg Date: Thu, 13 Nov 2025 16:07:27 -0500 Subject: [PATCH 11/14] Adds background color to color input --- assets/css/udoit4-theme.css | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/css/udoit4-theme.css b/assets/css/udoit4-theme.css index 408b29c14..d358ad003 100644 --- a/assets/css/udoit4-theme.css +++ b/assets/css/udoit4-theme.css @@ -814,6 +814,7 @@ input[type="color"] { height: 100%; border-radius: 8px; border: 2px solid var(--primary-color); + background-color: var(--background-color); &:focus { outline: 2px solid var(--focus-color); From e366926fa26049dffbb428da9975ca36852a2be0 Mon Sep 17 00:00:00 2001 From: Elias Wainberg Date: Thu, 20 Nov 2025 11:21:18 -0500 Subject: [PATCH 12/14] Adds show/hide button for gradient lists and screen reader description for valid/invalid colors --- assets/js/Components/Forms/ContrastForm.js | 55 +++++++++++++++------- translations/en.json | 3 ++ translations/es.json | 3 ++ 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/assets/js/Components/Forms/ContrastForm.js b/assets/js/Components/Forms/ContrastForm.js index 8b37d52c8..4564757c5 100644 --- a/assets/js/Components/Forms/ContrastForm.js +++ b/assets/js/Components/Forms/ContrastForm.js @@ -11,7 +11,7 @@ import * as Html from '../../Services/Html' import * as Contrast from '../../Services/Contrast' export default function ContrastForm({ - t, + t, settings, activeIssue, isDisabled, @@ -96,6 +96,7 @@ export default function ContrastForm({ const [contrastRatio, setContrastRatio] = useState(null) const [ratioIsValid, setRatioIsValid] = useState(false) const [autoAdjustError, setAutoAdjustError] = useState(false) + const [showAllColors, setShowAllColors] = useState(false) // Generate updated HTML with new colors const processHtml = (html, bgColors) => { @@ -264,7 +265,11 @@ export default function ContrastForm({ return (fontSizePt >= 18) || (isBold && fontSizePt >= 14); } - // Render + // In your render, before mapping colors: + const maxColorsToShow = 4; + const shouldShowExpand = currentBgColors.length > maxColorsToShow; + const visibleBgColors = showAllColors ? currentBgColors : currentBgColors.slice(0, maxColorsToShow); + return ( <>
{t('form.contrast.label.adjust')}
@@ -311,7 +316,7 @@ export default function ContrastForm({
- {currentBgColors.map((color, idx) => { + {visibleBgColors.map((color, idx) => { let showStatus = currentBgColors.length > 1; let tagName = Html.toElement(Html.getIssueHtml(activeIssue)).tagName; let minRatio = headingTags.includes(tagName) ? 3 : 4.5; @@ -328,24 +333,22 @@ export default function ContrastForm({ type="color" value={Contrast.hslToHex(color) || '#ffffff'} onChange={e => updateBackgroundColor(idx, e.target.value)} - aria-label={t('form.contrast.label.background.show_color_picker')} + aria-label={ + t('form.contrast.label.background.show_color_picker') + + ' ' + + (isValid + ? t('form.contrast.feedback.valid') + : t('form.contrast.feedback.invalid')) + } title={t('form.contrast.label.background.show_color_picker')} disabled={isDisabled} /> {showStatus && ( - isValid - ? - : + isValid ? ( + + ) : ( + + ) )}
@@ -372,6 +375,24 @@ export default function ContrastForm({ ); })} + {shouldShowExpand && ( +
+ {!showAllColors && ( +
+ {t('form.contrast.more_colors', { count: currentBgColors.length - maxColorsToShow })} +
+ )} + +
+ )} + {currentBgColors.length > 1 && (
{visibleBgColors.map((color, idx) => { let showStatus = currentBgColors.length > 1; - let tagName = Html.toElement(Html.getIssueHtml(activeIssue)).tagName; - let minRatio = headingTags.includes(tagName) ? 3 : 4.5; let ratio = Contrast.contrastRatio( Contrast.hslToHex(color), Contrast.hslToHex(textColor) ); @@ -377,11 +372,6 @@ export default function ContrastForm({ {shouldShowExpand && (
- {!showAllColors && ( -
- {t('form.contrast.more_colors', { count: currentBgColors.length - maxColorsToShow })} -
- )}
-
{contrastRatio}
+
{contrastRatio?.toFixed(2)}
-
- {ratioIsValid ? t('form.contrast.feedback.valid') : t('form.contrast.feedback.invalid')} +
+ {ratioIsValid + ? t('form.contrast.feedback.valid') + : ( + + {t('form.contrast.feedback.minimum', { + required: minRatio + })} + + ) + }
{currentBgColors.length > 1 && ( diff --git a/translations/en.json b/translations/en.json index da1b97c49..12fbb10e4 100644 --- a/translations/en.json +++ b/translations/en.json @@ -380,14 +380,13 @@ "form.contrast.replace_background": "Background Color", "form.contrast.label.ratio": "Contrast Ratio", "form.contrast.label.auto_adjust_all": "Auto-Adjust All Colors", - "form.contrast.feedback.invalid": "Insufficient Contrast", + "form.contrast.feedback.invalid": "Insufficient contrast of {current}", "form.contrast.feedback.valid": "Sufficient Contrast", + "form.contrast.feedback.minimum": "Minimum: {required}", "form.contrast.auto_adjust_error": "We couldn't auto-adjust your colors, please do so manually.", - "form.contrast.ratio_note": "* lowest among gradient colors", - "form.contrast.more_colors": "And {count} more colors", - "form.contrast.show": "Show", - "form.contrast.hide": "Hide", - "rule.desc.text_contrast_sufficient": "", + "form.contrast.ratio_note": "* lowest among all background colors", + "form.contrast.show": "Show all colors", + "form.contrast.hide": "Hide additional colors", "form.embedded_content_title.title": "Embedded Content Should Have a Label", "form.embedded_content_title.summary": "Embedded content, like videos or interactive elements, should have descriptive labels that help users understand what it is and what it does.", diff --git a/translations/es.json b/translations/es.json index adbc17906..8ccca9fdc 100644 --- a/translations/es.json +++ b/translations/es.json @@ -369,7 +369,9 @@ "form.contrast.title": "El Texto Debe Tener Suficiente Contraste de Color con el Fondo", "form.contrast.summary": "El contraste de color entre el texto y su fondo debe ser lo suficientemente alto para que los usuarios con baja visión o daltonismo puedan leerlo. ", "form.contrast.learn_more": "

Contraste de Color

Los usuarios con baja visión necesitan suficiente contraste para poder leer. La relación de contraste sigue una fórmula para comparar la luminancia del texto en primer plano con los colores de fondo. Presta atención al contraste especialmente en casos de cambio de tamaño del texto, diseño responsivo y efectos de movimiento como paralaje o fondos animados, cuando la posición del texto sobre su fondo puede cambiar.

Mejores Prácticas

  • Asegúrate de que el texto tenga suficiente contraste con su fondo
  • Logra el contraste mínimo para todo el texto colocado sobre imágenes o gradientes.

Para Más Información

WebAIM.org: Accesibilidad de Contraste y Color

Verificador de Contraste de Color En Línea

", - "form.contrast.label.show_color_picker": "Mostrar Selector de Color", + "form.contrast.label.adjust": "Ajusta los Colores del Texto y/o el Fondo para Mejorar el Contraste", + "form.contrast.label.text.show_color_picker": "Selector de Color de Texto", + "form.contrast.label.background.show_color_picker": "Selector de Color de Fondo", "form.contrast.label.hide_color_picker": "Ocultar Selector de Color", "form.contrast.label.lighten": "Aclarar", "form.contrast.label.darken": "Oscurecer", @@ -377,18 +379,15 @@ "form.contrast.replace_text": "Color del Texto", "form.contrast.replace_background": "Color de Fondo", "form.contrast.label.ratio": "Relación de Contraste", + "form.contrast.label.auto_adjust_all": "Ajustar Colores Automáticamente", "form.contrast.feedback.invalid": "Contraste Insuficiente", "form.contrast.feedback.valid": "Contraste Suficiente", - "form.contrast.label.bolden_text": "Poner Texto en Negrita", - "form.contrast.label.auto_adjust": "Ajustar Colores Automáticamente", - "form.contrast.label.italicize_text": "Poner Texto en Cursiva", + "form.contrast.feedback.minimum": "Mínimo: {required}", "form.contrast.auto_adjust_error": "No se pudo ajustar automáticamente el color. Intenta ajustar manualmente los colores.", "form.contrast.ratio_note": "* más bajo entre todos los colores", - "form.contrast.more_colors": "Y {count} más colores", - "form.contrast.show": "Mostrar", - "form.contrast.hide": "Ocultar", - "rule.desc.text_contrast_sufficient": "", - + "form.contrast.show": "Mostrar Colores Adicionales", + "form.contrast.hide": "Ocultar Colores Adicionales", + "form.embedded_content_title.title": "El Contenido Incrustado Debe Tener una Etiqueta", "form.embedded_content_title.summary": "El contenido incrustado, como videos o elementos interactivos, debe tener etiquetas descriptivas que ayuden a los usuarios a entender qué son y qué hacen.", "form.embedded_content_title.learn_more": "

Etiquetas Descriptivas

Para los usuarios con lectores de pantalla, el contenido incrustado requiere que ingresen manualmente y hagan "zoom in" para explorar lo que hay allí. Proporcionar una etiqueta es como tener un cartel en la puerta que describe lo que hay dentro. Con esta información, el usuario puede determinar si desea entrar y explorar en detalle.

Mejores Prácticas

  • Cuando incruste contenido, asegúrese de que el código de inserción incluya un atributo title con una etiqueta única que describa su contenido.
", From 5a7ba111c72d3016ddba1657dcc3dda6e93f5078 Mon Sep 17 00:00:00 2001 From: Elias Wainberg Date: Wed, 14 Jan 2026 12:30:46 -0500 Subject: [PATCH 14/14] Sets 'show all colors' to false on issues reload --- assets/js/Components/Forms/ContrastForm.js | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/js/Components/Forms/ContrastForm.js b/assets/js/Components/Forms/ContrastForm.js index 3ac0b2a16..bfbc39cb0 100644 --- a/assets/js/Components/Forms/ContrastForm.js +++ b/assets/js/Components/Forms/ContrastForm.js @@ -167,6 +167,7 @@ export default function ContrastForm({ setCurrentBgColors(info.map(bg => bg.hsl)) setTextColor(getTextColor()) setAutoAdjustError(false) + setShowAllColors(false) }, [activeIssue]) const updateBackgroundColor = (idx, value) => {