Files
chatc2/.pi/skills/impeccable/scripts/detector/engines/visual/screenshot-contrast.mjs
T
AyronSantos 12d419de7c feat: comprehensive design system overhaul
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
2026-06-23 16:58:12 +00:00

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,
};