| import { useEffect, useState } from 'react'; |
|
|
| export interface LayoutAnomaly { |
| id: string; |
| type: 'text-overflow' | 'layout-overflow' | 'grid-violation' | 'clipping'; |
| elementName: string; |
| selector: string; |
| message: string; |
| severity: 'warning' | 'critical'; |
| } |
|
|
| |
| |
| |
| |
| export function useLayoutValidator(active: boolean = true) { |
| const [anomalies, setAnomalies] = useState<LayoutAnomaly[]>([]); |
|
|
| useEffect(() => { |
| if (!active || typeof window === 'undefined') return; |
|
|
| let scanTimer: any; |
|
|
| const performAudit = () => { |
| const findings: LayoutAnomaly[] = []; |
|
|
| |
| if (document.documentElement.scrollWidth > window.innerWidth) { |
| findings.push({ |
| id: 'viewport-x-overflow', |
| type: 'layout-overflow', |
| elementName: 'Viewport (HTML/Body)', |
| selector: 'html', |
| message: `Viewport horizontal overflow detected: scrollWidth is ${document.documentElement.scrollWidth}px but innerWidth is ${window.innerWidth}px. Can cause platform horizontal scrolling.`, |
| severity: 'critical' |
| }); |
| } |
|
|
| |
| const elementsToAudit = document.querySelectorAll('p, span, h1, h2, h3, button, td, div.card, div.container'); |
| |
| elementsToAudit.forEach((el, index) => { |
| const clientWidth = el.clientWidth; |
| const scrollWidth = el.scrollWidth; |
| const clientHeight = el.clientHeight; |
| const scrollHeight = el.scrollHeight; |
|
|
| |
| const tagName = el.tagName.toLowerCase(); |
| const idStr = el.id ? `#${el.id}` : ''; |
| const classNames = Array.from(el.classList).slice(0, 2).map(c => `.${c}`).join(''); |
| const resolvedSelector = `${tagName}${idStr}${classNames} [idx:${index}]`; |
|
|
| |
| if (scrollWidth > clientWidth && clientWidth > 0) { |
| const style = window.getComputedStyle(el); |
| const isOverflowHiddenX = style.overflowX === 'hidden' || style.overflow === 'hidden'; |
| const isScrollableX = style.overflowX === 'scroll' || style.overflowX === 'auto'; |
|
|
| if (!isScrollableX && isOverflowHiddenX) { |
| findings.push({ |
| id: `text-overflow-${index}`, |
| type: 'text-overflow', |
| elementName: el.id || `${tagName} tag`, |
| selector: resolvedSelector, |
| message: `Potential clipped text detected: content width (${scrollWidth}px) exceeds container width (${clientWidth}px) under hidden overflow bounds.`, |
| severity: 'warning' |
| }); |
| } |
| } |
|
|
| |
| if (scrollHeight > clientHeight && clientHeight > 0) { |
| const style = window.getComputedStyle(el); |
| const isOverflowHiddenY = style.overflowY === 'hidden' || style.overflow === 'hidden'; |
| const isScrollableY = style.overflowY === 'scroll' || style.overflowY === 'auto'; |
|
|
| if (!isScrollableY && isOverflowHiddenY) { |
| findings.push({ |
| id: `vertical-clip-${index}`, |
| type: 'clipping', |
| elementName: el.id || `${tagName} tag`, |
| selector: resolvedSelector, |
| message: `Vertical text/content clipping: scrollHeight is ${scrollHeight}px but visible bounds are limited to ${clientHeight}px.`, |
| severity: 'warning' |
| }); |
| } |
| } |
|
|
| |
| const style = window.getComputedStyle(el); |
| const paddingLeft = parseFloat(style.paddingLeft) || 0; |
| const paddingRight = parseFloat(style.paddingRight) || 0; |
| const paddingTop = parseFloat(style.paddingTop) || 0; |
| const paddingBottom = parseFloat(style.paddingBottom) || 0; |
|
|
| const isPaddingViolation = (val: number) => val > 0 && val % 4 !== 0; |
| if (isPaddingViolation(paddingLeft) || isPaddingViolation(paddingTop)) { |
| findings.push({ |
| id: `grid-violation-${index}`, |
| type: 'grid-violation', |
| elementName: el.id || `${tagName} tag`, |
| selector: resolvedSelector, |
| message: `Padding values (${paddingLeft}px, ${paddingRight}px) do not align nicely with the 8px layout token scale.`, |
| severity: 'warning' |
| }); |
| } |
| }); |
|
|
| |
| setAnomalies(findings.slice(0, 10)); |
| }; |
|
|
| |
| const triggerAudit = () => { |
| cancelAnimationFrame(scanTimer); |
| scanTimer = requestAnimationFrame(performAudit); |
| }; |
|
|
| |
| window.addEventListener('resize', triggerAudit); |
| document.addEventListener('DOMSubtreeModified', triggerAudit); |
| |
| |
| const initialDelay = setTimeout(performAudit, 1000); |
|
|
| return () => { |
| window.removeEventListener('resize', triggerAudit); |
| document.removeEventListener('DOMSubtreeModified', triggerAudit); |
| clearTimeout(initialDelay); |
| cancelAnimationFrame(scanTimer); |
| }; |
| }, [active]); |
|
|
| return { anomalies }; |
| } |
|
|