From 4e251061b8cf99fdf8a0d360b5a7cfb521d91358 Mon Sep 17 00:00:00 2001 From: Abdul Wahab <32850166+abdulwahabone@users.noreply.github.com> Date: Sat, 6 Jun 2026 07:25:34 +0900 Subject: [PATCH] Fix sr-only text overflow false positive (#197) --- .../detector/detect-antipatterns-browser.js | 67 ++++++++ .../scripts/detector/rules/checks.mjs | 68 ++++++++ .../detector/detect-antipatterns-browser.js | 67 ++++++++ .../scripts/detector/rules/checks.mjs | 68 ++++++++ .../detector/detect-antipatterns-browser.js | 67 ++++++++ .../scripts/detector/rules/checks.mjs | 68 ++++++++ .../detector/detect-antipatterns-browser.js | 67 ++++++++ .../scripts/detector/rules/checks.mjs | 68 ++++++++ .../detector/detect-antipatterns-browser.js | 67 ++++++++ .../scripts/detector/rules/checks.mjs | 68 ++++++++ .../detector/detect-antipatterns-browser.js | 67 ++++++++ .../scripts/detector/rules/checks.mjs | 68 ++++++++ .../detector/detect-antipatterns-browser.js | 67 ++++++++ .../scripts/detector/rules/checks.mjs | 68 ++++++++ .../detector/detect-antipatterns-browser.js | 67 ++++++++ .../scripts/detector/rules/checks.mjs | 68 ++++++++ .../detector/detect-antipatterns-browser.js | 67 ++++++++ .../scripts/detector/rules/checks.mjs | 68 ++++++++ .../detector/detect-antipatterns-browser.js | 67 ++++++++ .../scripts/detector/rules/checks.mjs | 68 ++++++++ .../detector/detect-antipatterns-browser.js | 67 ++++++++ .../scripts/detector/rules/checks.mjs | 68 ++++++++ .../detector/detect-antipatterns-browser.js | 67 ++++++++ .../scripts/detector/rules/checks.mjs | 68 ++++++++ cli/engine/detect-antipatterns-browser.js | 67 ++++++++ cli/engine/rules/checks.mjs | 68 ++++++++ .../detector/detect-antipatterns-browser.js | 67 ++++++++ .../scripts/detector/rules/checks.mjs | 68 ++++++++ tests/detect-antipatterns-browser.test.mjs | 15 +- tests/detect-antipatterns.test.js | 149 ++++++++++++++++++ .../fixtures/antipatterns/text-overflow.html | 25 +++ 31 files changed, 2076 insertions(+), 3 deletions(-) diff --git a/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index f1535b97a..fcfaf6240 100644 --- a/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. diff --git a/.agents/skills/impeccable/scripts/detector/rules/checks.mjs b/.agents/skills/impeccable/scripts/detector/rules/checks.mjs index 70e90160c..0ffdf185c 100644 --- a/.agents/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.agents/skills/impeccable/scripts/detector/rules/checks.mjs @@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. @@ -2312,5 +2379,6 @@ export { checkClippedOverflow, checkElementClippedOverflow, checkElementClippedOverflowDOM, + isScreenReaderOnlyTextStyle, checkElementTextOverflowDOM, }; diff --git a/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index f1535b97a..fcfaf6240 100644 --- a/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. diff --git a/.claude/skills/impeccable/scripts/detector/rules/checks.mjs b/.claude/skills/impeccable/scripts/detector/rules/checks.mjs index 70e90160c..0ffdf185c 100644 --- a/.claude/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.claude/skills/impeccable/scripts/detector/rules/checks.mjs @@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. @@ -2312,5 +2379,6 @@ export { checkClippedOverflow, checkElementClippedOverflow, checkElementClippedOverflowDOM, + isScreenReaderOnlyTextStyle, checkElementTextOverflowDOM, }; diff --git a/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index f1535b97a..fcfaf6240 100644 --- a/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. diff --git a/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs b/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs index 70e90160c..0ffdf185c 100644 --- a/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs @@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. @@ -2312,5 +2379,6 @@ export { checkClippedOverflow, checkElementClippedOverflow, checkElementClippedOverflowDOM, + isScreenReaderOnlyTextStyle, checkElementTextOverflowDOM, }; diff --git a/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index f1535b97a..fcfaf6240 100644 --- a/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. diff --git a/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs b/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs index 70e90160c..0ffdf185c 100644 --- a/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs @@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. @@ -2312,5 +2379,6 @@ export { checkClippedOverflow, checkElementClippedOverflow, checkElementClippedOverflowDOM, + isScreenReaderOnlyTextStyle, checkElementTextOverflowDOM, }; diff --git a/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index f1535b97a..fcfaf6240 100644 --- a/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. diff --git a/.github/skills/impeccable/scripts/detector/rules/checks.mjs b/.github/skills/impeccable/scripts/detector/rules/checks.mjs index 70e90160c..0ffdf185c 100644 --- a/.github/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.github/skills/impeccable/scripts/detector/rules/checks.mjs @@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. @@ -2312,5 +2379,6 @@ export { checkClippedOverflow, checkElementClippedOverflow, checkElementClippedOverflowDOM, + isScreenReaderOnlyTextStyle, checkElementTextOverflowDOM, }; diff --git a/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index f1535b97a..fcfaf6240 100644 --- a/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. diff --git a/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs b/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs index 70e90160c..0ffdf185c 100644 --- a/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs @@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. @@ -2312,5 +2379,6 @@ export { checkClippedOverflow, checkElementClippedOverflow, checkElementClippedOverflowDOM, + isScreenReaderOnlyTextStyle, checkElementTextOverflowDOM, }; diff --git a/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index f1535b97a..fcfaf6240 100644 --- a/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. diff --git a/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs b/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs index 70e90160c..0ffdf185c 100644 --- a/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs @@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. @@ -2312,5 +2379,6 @@ export { checkClippedOverflow, checkElementClippedOverflow, checkElementClippedOverflowDOM, + isScreenReaderOnlyTextStyle, checkElementTextOverflowDOM, }; diff --git a/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index f1535b97a..fcfaf6240 100644 --- a/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. diff --git a/.pi/skills/impeccable/scripts/detector/rules/checks.mjs b/.pi/skills/impeccable/scripts/detector/rules/checks.mjs index 70e90160c..0ffdf185c 100644 --- a/.pi/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.pi/skills/impeccable/scripts/detector/rules/checks.mjs @@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. @@ -2312,5 +2379,6 @@ export { checkClippedOverflow, checkElementClippedOverflow, checkElementClippedOverflowDOM, + isScreenReaderOnlyTextStyle, checkElementTextOverflowDOM, }; diff --git a/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index f1535b97a..fcfaf6240 100644 --- a/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. diff --git a/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs b/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs index 70e90160c..0ffdf185c 100644 --- a/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs @@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. @@ -2312,5 +2379,6 @@ export { checkClippedOverflow, checkElementClippedOverflow, checkElementClippedOverflowDOM, + isScreenReaderOnlyTextStyle, checkElementTextOverflowDOM, }; diff --git a/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index f1535b97a..fcfaf6240 100644 --- a/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. diff --git a/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs b/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs index 70e90160c..0ffdf185c 100644 --- a/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs @@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. @@ -2312,5 +2379,6 @@ export { checkClippedOverflow, checkElementClippedOverflow, checkElementClippedOverflowDOM, + isScreenReaderOnlyTextStyle, checkElementTextOverflowDOM, }; diff --git a/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index f1535b97a..fcfaf6240 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. diff --git a/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs b/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs index 70e90160c..0ffdf185c 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs @@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. @@ -2312,5 +2379,6 @@ export { checkClippedOverflow, checkElementClippedOverflow, checkElementClippedOverflowDOM, + isScreenReaderOnlyTextStyle, checkElementTextOverflowDOM, }; diff --git a/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index f1535b97a..fcfaf6240 100644 --- a/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. diff --git a/.trae/skills/impeccable/scripts/detector/rules/checks.mjs b/.trae/skills/impeccable/scripts/detector/rules/checks.mjs index 70e90160c..0ffdf185c 100644 --- a/.trae/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.trae/skills/impeccable/scripts/detector/rules/checks.mjs @@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. @@ -2312,5 +2379,6 @@ export { checkClippedOverflow, checkElementClippedOverflow, checkElementClippedOverflowDOM, + isScreenReaderOnlyTextStyle, checkElementTextOverflowDOM, }; diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index f1535b97a..fcfaf6240 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. diff --git a/cli/engine/rules/checks.mjs b/cli/engine/rules/checks.mjs index 70e90160c..0ffdf185c 100644 --- a/cli/engine/rules/checks.mjs +++ b/cli/engine/rules/checks.mjs @@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. @@ -2312,5 +2379,6 @@ export { checkClippedOverflow, checkElementClippedOverflow, checkElementClippedOverflowDOM, + isScreenReaderOnlyTextStyle, checkElementTextOverflowDOM, }; diff --git a/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index f1535b97a..fcfaf6240 100644 --- a/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -2792,6 +2792,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2800,6 +2860,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. diff --git a/plugin/skills/impeccable/scripts/detector/rules/checks.mjs b/plugin/skills/impeccable/scripts/detector/rules/checks.mjs index 70e90160c..0ffdf185c 100644 --- a/plugin/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/plugin/skills/impeccable/scripts/detector/rules/checks.mjs @@ -2222,6 +2222,66 @@ function checkElementClippedOverflowDOM(el) { // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); +function metricLengthPx(value, fontSizePx = 16) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return null; + return resolveLengthPx(value, fontSizePx); +} + +function firstMetricLengthPx(fontSizePx, ...values) { + for (const value of values) { + const parsed = metricLengthPx(value, fontSizePx); + if (parsed !== null) return parsed; + } + return null; +} + +function expandBoxShorthand(parts) { + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function clippedByInset(clipPath) { + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); + if (!match) return false; + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); + if (!beforeRound) return false; + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); + if (percents.some(match => !match)) return false; + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); + return top + bottom >= 100 || left + right >= 100; +} + +function clippedByRect(clip) { + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); + if (!match) return false; + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); + if (values.length !== 4) return false; + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); + if ([top, right, bottom, left].some(value => value === null)) return false; + return bottom <= top || right <= left; +} + +function isScreenReaderOnlyTextStyle(style, metrics = {}) { + if (!style) return false; + const overflowValues = [style.overflow, style.overflowX, style.overflowY] + .map(value => String(value || '').toLowerCase()); + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); + + const fontSize = metricLengthPx(style.fontSize, 16) || 16; + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; + + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); + const clip = String(style.clip || '').trim(); + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; @@ -2230,6 +2290,13 @@ function checkElementTextOverflowDOM(el) { const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); if (!hasDirectText) return []; const style = getComputedStyle(el); + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; + if (isScreenReaderOnlyTextStyle(style, { + width: rect?.width, + height: rect?.height, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + })) return []; const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); if (isScrollRegion(style)) return []; // A scrollable ancestor means this overflow is intentional and scrollable. @@ -2312,5 +2379,6 @@ export { checkClippedOverflow, checkElementClippedOverflow, checkElementClippedOverflowDOM, + isScreenReaderOnlyTextStyle, checkElementTextOverflowDOM, }; diff --git a/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs index 9388de8d2..4fa61185a 100644 --- a/tests/detect-antipatterns-browser.test.mjs +++ b/tests/detect-antipatterns-browser.test.mjs @@ -154,8 +154,8 @@ describe('detectUrl — browser-only fixtures', () => { // Browser-only: needs scrollWidth vs clientWidth from real layout. // Flag column: a nowrap line and an unbreakable token spilling past a // fixed-width box (overflow visible). Pass column: a genuine - // overflow-x:auto scroll region, a
, normally wrapping text, and a
-    // long line living inside a scroll ancestor.
+    // overflow-x:auto scroll region, a 
, normally wrapping text, a long
+    // line living inside a scroll ancestor, and sr-only accessible text.
     const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/text-overflow.html`);
     const hits = f.filter(r => r.antipattern === 'text-overflow');
     const flagged = new Set();
@@ -165,7 +165,16 @@ describe('detectUrl — browser-only fixtures', () => {
     }
     assert.ok(flagged.has('flag-nowrap'), 'expected the nowrap overflow case to flag');
     assert.ok(flagged.has('flag-longword'), 'expected the unbreakable-token overflow case to flag');
-    for (const cls of ['pass-scroll', 'pass-pre', 'pass-wrap', 'pass-inside-scroll']) {
+    for (const cls of [
+      'pass-scroll',
+      'pass-pre',
+      'pass-wrap',
+      'pass-inside-scroll',
+      'pass-sr-only-clip-path',
+      'pass-sr-only-legacy',
+      'pass-sr-only-tiny-hidden',
+      'pass-sr-only-clipped-wide',
+    ]) {
       assert.ok(!flagged.has(cls), `".${cls}" should NOT be flagged as text-overflow`);
     }
     assert.equal(hits.length, 2, `expected exactly 2 text-overflow findings, got ${hits.length}: ${JSON.stringify(hits.map(h => h.snippet))}`);
diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js
index 73465149b..935d30673 100644
--- a/tests/detect-antipatterns.test.js
+++ b/tests/detect-antipatterns.test.js
@@ -10,6 +10,10 @@ import {
   buildImportGraph, resolveImport,
   detectFrameworkConfig, isPortListening, FRAMEWORK_CONFIGS,
 } from '../cli/engine/detect-antipatterns.mjs';
+import {
+  checkElementTextOverflowDOM,
+  isScreenReaderOnlyTextStyle,
+} from '../cli/engine/rules/checks.mjs';
 
 const FIXTURES = path.join(import.meta.dir, 'fixtures', 'antipatterns');
 const SCRIPT = path.join(import.meta.dir, '..', 'cli', 'engine', 'detect-antipatterns.mjs');
@@ -254,6 +258,151 @@ describe('detectHtml — layout', () => {
 
 });
 
+// ---------------------------------------------------------------------------
+// Text overflow screen-reader-only handling
+// ---------------------------------------------------------------------------
+
+describe('checkElementTextOverflowDOM', () => {
+  function baseTextStyle(overrides = {}) {
+    return {
+      position: 'static',
+      width: '160px',
+      height: '20px',
+      overflow: 'visible',
+      overflowX: 'visible',
+      overflowY: 'visible',
+      clipPath: 'none',
+      clip: 'auto',
+      ...overrides,
+    };
+  }
+
+  function mockTextElement({
+    className = 'flag-overflow',
+    style = baseTextStyle(),
+    clientWidth = 24,
+    clientHeight = 20,
+    scrollWidth = 80,
+    rectWidth = clientWidth,
+    rectHeight = clientHeight,
+  } = {}) {
+    return {
+      tagName: 'DIV',
+      className,
+      childNodes: [{ nodeType: 3, textContent: 'A long accessible label that overflows its box' }],
+      parentElement: null,
+      clientWidth,
+      clientHeight,
+      scrollWidth,
+      __style: style,
+      getAttribute(name) {
+        return name === 'class' ? className : null;
+      },
+      getBoundingClientRect() {
+        return { width: rectWidth, height: rectHeight };
+      },
+    };
+  }
+
+  function withMockComputedStyle(callback) {
+    const original = globalThis.getComputedStyle;
+    globalThis.getComputedStyle = (el) => el.__style;
+    try {
+      return callback();
+    } finally {
+      if (original === undefined) delete globalThis.getComputedStyle;
+      else globalThis.getComputedStyle = original;
+    }
+  }
+
+  test('classifies clip-path sr-only text as visually hidden', () => {
+    expect(isScreenReaderOnlyTextStyle(baseTextStyle({
+      position: 'absolute',
+      width: '1px',
+      height: '1px',
+      overflow: 'hidden',
+      overflowX: 'hidden',
+      overflowY: 'hidden',
+      clipPath: 'inset(50%)',
+    }), { width: 1, height: 1 })).toBe(true);
+  });
+
+  test('classifies legacy clip rect sr-only text as visually hidden', () => {
+    expect(isScreenReaderOnlyTextStyle(baseTextStyle({
+      position: 'absolute',
+      width: '1px',
+      height: '1px',
+      overflow: 'hidden',
+      overflowX: 'hidden',
+      overflowY: 'hidden',
+      clip: 'rect(0, 0, 0, 0)',
+    }), { width: 1, height: 1 })).toBe(true);
+  });
+
+  test('classifies tiny absolute overflow-hidden text as visually hidden without clip', () => {
+    expect(isScreenReaderOnlyTextStyle(baseTextStyle({
+      position: 'absolute',
+      width: '1px',
+      height: '1px',
+      overflow: 'hidden',
+      overflowX: 'hidden',
+      overflowY: 'hidden',
+    }), { width: 1, height: 1 })).toBe(true);
+  });
+
+  test('classifies fully clipped text as visually hidden without tiny sizing', () => {
+    expect(isScreenReaderOnlyTextStyle(baseTextStyle({
+      position: 'absolute',
+      width: '160px',
+      height: '20px',
+      overflow: 'visible',
+      clipPath: 'inset(50%)',
+    }), { width: 160, height: 20 })).toBe(true);
+  });
+
+  test('flags visible overflowing text', () => {
+    const findings = withMockComputedStyle(() => checkElementTextOverflowDOM(mockTextElement()));
+
+    expect(findings).toHaveLength(1);
+    expect(findings[0].id).toBe('text-overflow');
+    expect(findings[0].snippet).toContain('.flag-overflow');
+  });
+
+  test('skips overflowing sr-only text', () => {
+    const srOnly = mockTextElement({
+      className: 'pass-sr-only-clip-path',
+      style: baseTextStyle({
+        position: 'absolute',
+        width: '1px',
+        height: '1px',
+        overflow: 'hidden',
+        overflowX: 'hidden',
+        overflowY: 'hidden',
+        clipPath: 'inset(50%)',
+      }),
+      clientWidth: 1,
+      clientHeight: 1,
+      scrollWidth: 240,
+      rectWidth: 1,
+      rectHeight: 1,
+    });
+
+    const findings = withMockComputedStyle(() => checkElementTextOverflowDOM(srOnly));
+
+    expect(findings).toHaveLength(0);
+  });
+
+  test('does not classify tiny visible text as sr-only', () => {
+    const style = baseTextStyle({
+      position: 'absolute',
+      width: '1px',
+      height: '1px',
+    });
+
+    expect(isScreenReaderOnlyTextStyle(style, { width: 1, height: 1 })).toBe(false);
+  });
+});
+
 // ---------------------------------------------------------------------------
 // Motion anti-patterns
 // ---------------------------------------------------------------------------
diff --git a/tests/fixtures/antipatterns/text-overflow.html b/tests/fixtures/antipatterns/text-overflow.html
index 728d3a697..cc05ba873 100644
--- a/tests/fixtures/antipatterns/text-overflow.html
+++ b/tests/fixtures/antipatterns/text-overflow.html
@@ -8,6 +8,27 @@
     .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; padding: 24px; align-items: start; }
     .col { padding: 16px; }
     .box { width: 160px; border: 1px solid #ddd; margin: 0 0 24px; padding: 8px; }
+    .sr-only {
+      position: absolute;
+      width: 1px;
+      height: 1px;
+      padding: 0;
+      margin: -1px;
+      overflow: hidden;
+      white-space: nowrap;
+      border-width: 0;
+    }
+    .sr-only-clip-path { clip-path: inset(50%); }
+    .sr-only-legacy-clip { clip: rect(0, 0, 0, 0); }
+    .sr-only-tiny-hidden { clip: auto; }
+    .sr-only-clipped-wide {
+      position: absolute;
+      width: 160px;
+      height: 20px;
+      overflow: visible;
+      white-space: nowrap;
+      clip-path: inset(50%);
+    }
   
 
 
@@ -24,6 +45,10 @@
       
a-very-long-preformatted-code-line-that-intentionally-extends-beyond-the-box-width
Ordinary wrapping paragraph text that stays comfortably within its container.
A long line living inside a scroll ancestor, which must not be flagged.
+ A screen-reader-only label with long text that should never draw a visible overflow finding. + A legacy clipped screen-reader-only label with long text that should not be flagged. + A tiny overflow-hidden screen-reader-only label without a clip declaration should not be flagged. + A fully clipped label with a normal-sized box and overflowing text should not be flagged.