// Pure logic of the live tools. No React here, so it stays easy to test. export type OutputTone = "default" | "muted" | "success" | "error" export type OutputLine = { text: string; tone?: OutputTone } /* ── Contrast check (WCAG 2.2) ──────────────────────────────────────── */ export function parseHex(input: string): [number, number, number] | null { const hex = input.trim().replace(/^#/, "") const full = hex.length === 3 ? hex .split("") .map((c) => c + c) .join("") : hex if (!/^[0-9a-f]{6}$/i.test(full)) return null return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16)) as [ number, number, number, ] } function relativeLuminance([r, g, b]: [number, number, number]) { const channel = (value: number) => { const c = value / 255 return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 } return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) } export function contrastRatio( a: [number, number, number], b: [number, number, number] ) { const [light, dark] = [relativeLuminance(a), relativeLuminance(b)].sort( (x, y) => y - x ) return (light + 0.05) / (dark + 0.05) } export function runContrastCheck(fg: string, bg: string): OutputLine[] { const command: OutputLine = { text: `$ kontrast --text ${fg} --hintergrund ${bg}`, tone: "muted", } const fgRgb = parseHex(fg) const bgRgb = parseHex(bg) if (!fgRgb || !bgRgb) { return [ command, { text: "fehler: bitte Farben als Hex angeben, z. B. #1a1a1a", tone: "error" }, ] } // Floor instead of round, otherwise 4.499 would be reported as passing 4.5 const ratio = Math.floor(contrastRatio(fgRgb, bgRgb) * 100) / 100 const check = (label: string, min: number): OutputLine => { const pass = ratio >= min return { text: `${label.padEnd(22)}${pass ? "bestanden" : "nicht bestanden"} (min. ${min}:1)`, tone: pass ? "success" : "error", } } return [ command, { text: `verhältnis ${ratio.toFixed(2)} : 1` }, check("AA fließtext", 4.5), check("AA große schrift", 3), check("AA bedienelemente", 3), check("AAA fließtext", 7), ] } /* ── Search snippet check ───────────────────────────────────────────── */ const TITLE_RANGE = [30, 60] as const const DESCRIPTION_RANGE = [70, 160] as const function rangeLine( label: string, length: number, [min, max]: readonly [number, number] ): OutputLine { const ok = length >= min && length <= max const hint = length < min ? "zu kurz" : length > max ? "wird abgeschnitten" : "passt" return { text: `${label.padEnd(16)}${String(length).padStart(3)} zeichen ${hint} (${min}–${max})`, tone: ok ? "success" : "error", } } export function runSnippetCheck(title: string, description: string): OutputLine[] { const lines: OutputLine[] = [ { text: "$ snippet --pruefen", tone: "muted" }, rangeLine("titel", title.trim().length, TITLE_RANGE), rangeLine("beschreibung", description.trim().length, DESCRIPTION_RANGE), ] const titleWords = new Set( title .toLowerCase() .split(/[^\p{L}\p{N}]+/u) .filter((word) => word.length > 3) ) const shared = description .toLowerCase() .split(/[^\p{L}\p{N}]+/u) .filter((word) => titleWords.has(word)) lines.push( shared.length > 0 ? { text: `suchbegriff in beiden: ${[...new Set(shared)].join(", ")}`, tone: "success" } : { text: "suchbegriff titel und beschreibung teilen kein wort", tone: "error", } ) return lines } export function truncate(text: string, max: number) { const trimmed = text.trim() return trimmed.length > max ? `${trimmed.slice(0, max - 1).trimEnd()} …` : trimmed }