12d419de7c
Applied impeccable design critique, audit, harden, layout, distill, and polish across all pages. DESIGN SYSTEM: - Created PRODUCT.md (5 design principles, WCAG 2.1 AA target) - Created DESIGN.md (34 color tokens, 6 radius scale, full component spec) - Created .impeccable/design.json (sidecar with tonal ramps, shadows, motion) ACCESSIBILITY: - 33 aria-labels across 7 pages (was ~13) - 63 <label> elements (was ~30) - 6 <main> landmarks + role=navigation on all sidebars - Esc to close modals on 4 pages - Keyboard shortcuts (Ctrl+J/K, Ctrl+F) on chat THEMING: - 216 hardcoded colors replaced with var() references - 449 !important removed from dark-mode.css - Dark mode script unified (was duplicated 9x inline) - All modals converted to .modal-overlay + .modal design system classes CHAT: - Error states for messages (.msg.erro, .msg.enviando) - Connection status indicator - Confirmation modal before finalizar - Input bar reorganized (3 visible actions, + menu) - Right panel accordions - Send button disabled when empty - Touch targets 44px on mobile CLIENT DETAIL: - Tabs/badges/sub-tabs use CSS classes instead of inline styles - 'Iniciar Conversa' button now primary action - alert() replaced with inline feedback - Modal converted to design system classes CLIENT LIST: - Modal moved inside <body> (was HTML-invalid) - alert() replaced with inline feedback - Modal uses .show pattern SETTINGS: - 4 modals converted to design system classes - 14 alert() calls replaced with inline feedback - fecharModal() uses classList instead of style.display ROUTES: - border-radius normalized, hardcoded colors fixed Score progression: Chat: 23 → 32/40 Client List: 32 → 35/40 Client Detail: 26 → 30/40 Settings: 29 → 35/40 Routes: 37 → 38/40 Audit (project): 14/20 → 17/20
190 lines
6.4 KiB
JavaScript
190 lines
6.4 KiB
JavaScript
function sanitizeScreenshotClip(clip, viewport) {
|
|
if (!clip) return null;
|
|
const x = Math.max(0, Math.floor(clip.x || 0));
|
|
const y = Math.max(0, Math.floor(clip.y || 0));
|
|
const width = Math.min(
|
|
Math.max(1, Math.ceil(clip.width || 0)),
|
|
Math.max(1, viewport?.width || 1600),
|
|
);
|
|
const height = Math.min(
|
|
Math.max(1, Math.ceil(clip.height || 0)),
|
|
320,
|
|
);
|
|
if (width < 1 || height < 1) return null;
|
|
return { x, y, width, height };
|
|
}
|
|
|
|
async function compareScreenshotContrast(page, beforeBase64, afterBase64, candidate) {
|
|
return page.evaluate(async ({ beforeBase64, afterBase64, candidate }) => {
|
|
const loadImage = (base64) => new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.onload = () => resolve(img);
|
|
img.onerror = () => reject(new Error('Could not decode contrast screenshot'));
|
|
img.src = `data:image/png;base64,${base64}`;
|
|
});
|
|
const [before, after] = await Promise.all([loadImage(beforeBase64), loadImage(afterBase64)]);
|
|
const width = Math.min(before.width, after.width);
|
|
const height = Math.min(before.height, after.height);
|
|
if (width < 1 || height < 1) return null;
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
if (!ctx) return null;
|
|
|
|
ctx.drawImage(before, 0, 0, width, height);
|
|
const beforePixels = ctx.getImageData(0, 0, width, height).data;
|
|
ctx.clearRect(0, 0, width, height);
|
|
ctx.drawImage(after, 0, 0, width, height);
|
|
const afterPixels = ctx.getImageData(0, 0, width, height).data;
|
|
|
|
const luminance = ({ r, g, b }) => {
|
|
const convert = c => {
|
|
const v = c / 255;
|
|
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
};
|
|
return 0.2126 * convert(r) + 0.7152 * convert(g) + 0.0722 * convert(b);
|
|
};
|
|
const ratio = (a, b) => {
|
|
const l1 = luminance(a);
|
|
const l2 = luminance(b);
|
|
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
};
|
|
|
|
const cssTextColor = candidate.textColor && !candidate.preferRenderedForeground
|
|
? {
|
|
r: candidate.textColor.r,
|
|
g: candidate.textColor.g,
|
|
b: candidate.textColor.b,
|
|
}
|
|
: null;
|
|
const ratios = [];
|
|
let glyphPixels = 0;
|
|
let strongestDelta = 0;
|
|
for (let i = 0; i < beforePixels.length; i += 4) {
|
|
const delta = Math.abs(beforePixels[i] - afterPixels[i])
|
|
+ Math.abs(beforePixels[i + 1] - afterPixels[i + 1])
|
|
+ Math.abs(beforePixels[i + 2] - afterPixels[i + 2])
|
|
+ Math.abs(beforePixels[i + 3] - afterPixels[i + 3]);
|
|
strongestDelta = Math.max(strongestDelta, delta);
|
|
if (delta < 10) continue;
|
|
glyphPixels++;
|
|
const fg = cssTextColor || {
|
|
r: beforePixels[i],
|
|
g: beforePixels[i + 1],
|
|
b: beforePixels[i + 2],
|
|
};
|
|
const bg = {
|
|
r: afterPixels[i],
|
|
g: afterPixels[i + 1],
|
|
b: afterPixels[i + 2],
|
|
};
|
|
ratios.push(ratio(fg, bg));
|
|
}
|
|
|
|
if (ratios.length < 8) {
|
|
return {
|
|
glyphPixels,
|
|
strongestDelta,
|
|
worstRatio: null,
|
|
p10Ratio: null,
|
|
medianRatio: null,
|
|
};
|
|
}
|
|
|
|
ratios.sort((a, b) => a - b);
|
|
const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))];
|
|
return {
|
|
glyphPixels,
|
|
strongestDelta,
|
|
worstRatio: ratios[0],
|
|
p10Ratio: pick(10),
|
|
medianRatio: pick(50),
|
|
};
|
|
}, { beforeBase64, afterBase64, candidate });
|
|
}
|
|
|
|
async function captureVisualContrastCandidate(page, candidate, viewport) {
|
|
const clip = sanitizeScreenshotClip(candidate.clip, viewport);
|
|
if (!clip) return null;
|
|
|
|
const beforeBase64 = await page.screenshot({
|
|
encoding: 'base64',
|
|
clip,
|
|
captureBeyondViewport: true,
|
|
});
|
|
const token = `impeccable-contrast-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const applied = await page.evaluate(({ selector, token, backgroundClipText }) => {
|
|
let el;
|
|
try {
|
|
el = document.querySelector(selector);
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (!el) return false;
|
|
let style = document.getElementById('impeccable-visual-contrast-hide-style');
|
|
if (!style) {
|
|
style = document.createElement('style');
|
|
style.id = 'impeccable-visual-contrast-hide-style';
|
|
style.textContent = [
|
|
'[data-impeccable-visual-contrast-target] {',
|
|
' color: transparent !important;',
|
|
' -webkit-text-fill-color: transparent !important;',
|
|
' text-shadow: none !important;',
|
|
'}',
|
|
'[data-impeccable-visual-contrast-target][data-impeccable-bgclip-text="true"] {',
|
|
' background-image: none !important;',
|
|
'}',
|
|
].join('\n');
|
|
document.head.appendChild(style);
|
|
}
|
|
el.setAttribute('data-impeccable-visual-contrast-target', token);
|
|
if (backgroundClipText) el.setAttribute('data-impeccable-bgclip-text', 'true');
|
|
return true;
|
|
}, {
|
|
selector: candidate.selector,
|
|
token,
|
|
backgroundClipText: candidate.backgroundClipText,
|
|
});
|
|
if (!applied) return null;
|
|
|
|
let afterBase64;
|
|
try {
|
|
afterBase64 = await page.screenshot({
|
|
encoding: 'base64',
|
|
clip,
|
|
captureBeyondViewport: true,
|
|
});
|
|
} finally {
|
|
await page.evaluate(({ selector }) => {
|
|
try {
|
|
const el = document.querySelector(selector);
|
|
if (el) {
|
|
el.removeAttribute('data-impeccable-visual-contrast-target');
|
|
el.removeAttribute('data-impeccable-bgclip-text');
|
|
}
|
|
} catch {
|
|
// Ignore invalid or stale selectors during cleanup.
|
|
}
|
|
}, { selector: candidate.selector }).catch(() => {});
|
|
}
|
|
|
|
const metrics = await compareScreenshotContrast(page, beforeBase64, afterBase64, candidate);
|
|
if (!metrics || !Number.isFinite(metrics.p10Ratio) || metrics.glyphPixels < 8) return null;
|
|
const measuredRatio = metrics.p10Ratio;
|
|
if (measuredRatio >= candidate.threshold) return null;
|
|
const textLabel = candidate.text ? ` "${candidate.text}"` : '';
|
|
const reasonLabel = (candidate.reasons || []).slice(0, 3).join(', ') || 'visual background';
|
|
return {
|
|
id: 'low-contrast',
|
|
snippet: `pixel contrast ${measuredRatio.toFixed(1)}:1 median ${metrics.medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) on ${reasonLabel}${textLabel}`,
|
|
};
|
|
}
|
|
|
|
export {
|
|
sanitizeScreenshotClip,
|
|
compareScreenshotContrast,
|
|
captureVisualContrastCandidate,
|
|
};
|