// Text fragility analysis for DevTool
// Detects text elements at risk of overflow, truncation, or layout shifts
(function() {
'use strict';
var utils = window.__devtool_utils;
// Standard breakpoints to test
var BREAKPOINTS = [320, 375, 414, 768, 1024, 1280, 1440, 1920];
/**
* Get all text-containing elements on the page
*/
function getTextElements() {
var elements = [];
var walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_ELEMENT,
{
acceptNode: function(node) {
// Skip script, style, and hidden elements
if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE' ||
node.tagName === 'NOSCRIPT' || node.tagName === 'SVG') {
return NodeFilter.FILTER_REJECT;
}
// Check if element has direct text content
var hasText = Array.from(node.childNodes).some(function(child) {
return child.nodeType === Node.TEXT_NODE && child.textContent.trim().length > 0;
});
if (hasText) {
return NodeFilter.FILTER_ACCEPT;
}
return NodeFilter.FILTER_SKIP;
}
}
);
var node;
while ((node = walker.nextNode())) {
elements.push(node);
}
return elements;
}
/**
* Get the longest word in an element's text content
*/
function getLongestWord(element) {
var text = '';
Array.from(element.childNodes).forEach(function(child) {
if (child.nodeType === Node.TEXT_NODE) {
text += child.textContent;
}
});
var words = text.split(/\s+/).filter(function(w) { return w.length > 0; });
if (words.length === 0) return { word: '', length: 0 };
var longest = words.reduce(function(a, b) {
return a.length > b.length ? a : b;
});
return { word: longest, length: longest.length };
}
/**
* Measure the pixel width of text using a hidden span
*/
function measureTextWidth(text, element) {
var span = document.createElement('span');
var computed = window.getComputedStyle(element);
span.style.cssText = [
'position: absolute',
'visibility: hidden',
'white-space: nowrap',
'font-family: ' + computed.fontFamily,
'font-size: ' + computed.fontSize,
'font-weight: ' + computed.fontWeight,
'font-style: ' + computed.fontStyle,
'letter-spacing: ' + computed.letterSpacing,
'text-transform: ' + computed.textTransform
].join(';');
span.textContent = text;
document.body.appendChild(span);
var width = span.offsetWidth;
document.body.removeChild(span);
return width;
}
/**
* Check if element has text overflow issues
*/
function checkTextOverflow(element) {
var computed = window.getComputedStyle(element);
var issues = [];
// Check for truncation settings
var hasEllipsis = computed.textOverflow === 'ellipsis';
var hasNowrap = computed.whiteSpace === 'nowrap';
var hasHiddenOverflow = computed.overflow === 'hidden' ||
computed.overflowX === 'hidden';
// Check actual overflow
var isOverflowing = element.scrollWidth > element.clientWidth;
var isVerticallyOverflowing = element.scrollHeight > element.clientHeight;
// Check if text is actually truncated
var isTruncated = hasEllipsis && hasHiddenOverflow && isOverflowing;
if (isTruncated) {
issues.push({
type: 'truncated',
severity: 'warning',
message: 'Text is truncated with ellipsis',
details: {
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth,
overflow: Math.round(element.scrollWidth - element.clientWidth) + 'px'
}
});
}
// Text clipped by overflow:hidden without ellipsis - silent content loss
var isClipped = isOverflowing && hasHiddenOverflow && !hasEllipsis;
if (isClipped) {
issues.push({
type: 'clipped',
severity: 'error',
message: 'Text clipped by overflow:hidden without ellipsis - content is silently lost',
details: {
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth,
hiddenContent: Math.round(element.scrollWidth - element.clientWidth) + 'px',
suggestion: 'Add text-overflow: ellipsis, or allow text to wrap, or increase container width'
}
});
}
if (isOverflowing && !hasHiddenOverflow) {
issues.push({
type: 'horizontal-overflow',
severity: 'error',
message: 'Text overflows container horizontally',
details: {
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth,
overflow: Math.round(element.scrollWidth - element.clientWidth) + 'px'
}
});
}
// Check for vertical clipping vs overflow
var hasVerticalHiddenOverflow = computed.overflow === 'hidden' || computed.overflowY === 'hidden';
var isVerticallyClipped = isVerticallyOverflowing && hasVerticalHiddenOverflow;
if (isVerticallyClipped) {
issues.push({
type: 'vertical-clipped',
severity: 'error',
message: 'Text clipped vertically by overflow:hidden - content is silently lost',
details: {
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
hiddenContent: Math.round(element.scrollHeight - element.clientHeight) + 'px',
suggestion: 'Increase container height, use max-height with overflow:auto, or use line-clamp'
}
});
} else if (isVerticallyOverflowing && computed.overflowY !== 'scroll' && computed.overflowY !== 'auto') {
issues.push({
type: 'vertical-overflow',
severity: 'error',
message: 'Text overflows container vertically',
details: {
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
overflow: Math.round(element.scrollHeight - element.clientHeight) + 'px'
}
});
}
return issues;
}
/**
* Calculate minimum width needed for longest word
*/
function getMinWidthForLongestWord(element) {
var longestWord = getLongestWord(element);
if (longestWord.length === 0) return { width: 0, word: '' };
var width = measureTextWidth(longestWord.word, element);
var computed = window.getComputedStyle(element);
// Add padding
var paddingLeft = parseFloat(computed.paddingLeft) || 0;
var paddingRight = parseFloat(computed.paddingRight) || 0;
return {
width: Math.ceil(width + paddingLeft + paddingRight),
word: longestWord.word,
wordLength: longestWord.length
};
}
/**
* Find breakpoints where text would cause issues
*/
function findProblematicBreakpoints(element) {
var minWidth = getMinWidthForLongestWord(element);
if (minWidth.width === 0) return [];
var problematic = [];
var elementWidth = element.clientWidth;
BREAKPOINTS.forEach(function(bp) {
// Estimate element width at this breakpoint
// This is a simplification - actual width depends on layout
var ratio = bp / window.innerWidth;
var estimatedWidth = elementWidth * ratio;
if (estimatedWidth < minWidth.width) {
problematic.push({
breakpoint: bp,
estimatedWidth: Math.round(estimatedWidth),
requiredWidth: minWidth.width,
deficit: Math.round(minWidth.width - estimatedWidth)
});
}
});
return problematic;
}
/**
* Check for layout shift risk factors
*/
function checkLayoutShiftRisk(element) {
var computed = window.getComputedStyle(element);
var risks = [];
// Check for auto height with dynamic content potential
var hasAutoHeight = computed.height === 'auto' || !computed.height;
var hasMinHeight = computed.minHeight && computed.minHeight !== '0px';
var hasMaxHeight = computed.maxHeight && computed.maxHeight !== 'none';
// Elements with auto height and no constraints are shift risks
if (hasAutoHeight && !hasMinHeight && !hasMaxHeight) {
var lineHeight = parseFloat(computed.lineHeight) || parseFloat(computed.fontSize) * 1.2;
var lines = Math.ceil(element.scrollHeight / lineHeight);
if (lines > 1) {
risks.push({
type: 'multi-line-auto-height',
severity: 'warning',
message: 'Multi-line text with auto height - content changes may cause layout shift',
details: {
estimatedLines: lines,
lineHeight: Math.round(lineHeight) + 'px'
}
});
}
}
// Check for word-break or overflow-wrap settings
var hasWordBreak = computed.wordBreak === 'break-all' || computed.wordBreak === 'break-word';
var hasOverflowWrap = computed.overflowWrap === 'break-word' || computed.overflowWrap === 'anywhere';
if (!hasWordBreak && !hasOverflowWrap) {
var minWidth = getMinWidthForLongestWord(element);
if (minWidth.wordLength > 15) {
risks.push({
type: 'long-word-no-break',
severity: 'warning',
message: 'Long word (' + minWidth.wordLength + ' chars) without word-break may overflow',
details: {
word: minWidth.word.substring(0, 20) + (minWidth.word.length > 20 ? '...' : ''),
minWidthNeeded: minWidth.width + 'px'
}
});
}
}
return risks;
}
/**
* Main text fragility check function
*/
function checkTextFragility() {
try {
var elements = getTextElements();
var issues = [];
var summary = {
total: 0,
errors: 0,
warnings: 0,
elementsAnalyzed: elements.length
};
elements.forEach(function(element) {
var selector = utils.generateSelector(element);
var elementIssues = [];
// Check for overflow issues
var overflowIssues = checkTextOverflow(element);
elementIssues = elementIssues.concat(overflowIssues);
// Check for layout shift risks
var shiftRisks = checkLayoutShiftRisk(element);
elementIssues = elementIssues.concat(shiftRisks);
// Find problematic breakpoints
var breakpointIssues = findProblematicBreakpoints(element);
if (elementIssues.length > 0 || breakpointIssues.length > 0) {
var longestWord = getLongestWord(element);
var minWidth = getMinWidthForLongestWord(element);
issues.push({
selector: selector,
text: element.textContent.substring(0, 50).trim() +
(element.textContent.length > 50 ? '...' : ''),
longestWord: {
word: longestWord.word.substring(0, 30) +
(longestWord.word.length > 30 ? '...' : ''),
length: longestWord.length,
minWidthPx: minWidth.width
},
issues: elementIssues,
problematicBreakpoints: breakpointIssues
});
elementIssues.forEach(function(issue) {
summary.total++;
if (issue.severity === 'error') {
summary.errors++;
} else {
summary.warnings++;
}
});
// Count breakpoint issues
if (breakpointIssues.length > 0) {
summary.total++;
summary.warnings++;
}
}
});
// Sort by severity (errors first)
issues.sort(function(a, b) {
var aHasError = a.issues.some(function(i) { return i.severity === 'error'; });
var bHasError = b.issues.some(function(i) { return i.severity === 'error'; });
if (aHasError && !bHasError) return -1;
if (!aHasError && bHasError) return 1;
return b.issues.length - a.issues.length;
});
return {
issues: issues,
summary: summary,
breakpointsTested: BREAKPOINTS
};
} catch (e) {
return { error: e.message };
}
}
// Export
window.__devtool_text_fragility = {
checkTextFragility: checkTextFragility,
getTextElements: getTextElements,
getLongestWord: getLongestWord,
measureTextWidth: measureTextWidth,
getMinWidthForLongestWord: getMinWidthForLongestWord
};
})();