From e4d8dd21f94ef7f38d8f831d68ec1ae3168c23f0 Mon Sep 17 00:00:00 2001 From: developeranku Date: Wed, 6 May 2026 19:12:27 +0530 Subject: [PATCH] feat: color contrast linter with non-blocking editor banner --- src/editor/Editor.tsx | 27 +++++++++++++++- src/editor/editor.css | 28 ++++++++++++++++ src/render/lint.ts | 67 +++++++++++++++++++++++++++++++++++++++ tests/render/lint.test.ts | 34 ++++++++++++++++++++ 4 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 src/render/lint.ts create mode 100644 tests/render/lint.test.ts diff --git a/src/editor/Editor.tsx b/src/editor/Editor.tsx index c07b821..dc46638 100644 --- a/src/editor/Editor.tsx +++ b/src/editor/Editor.tsx @@ -7,12 +7,14 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ParseError, parseDeck } from '@/ir/parse'; import { planDeck } from '@/ir/plan'; import { reorderSlide } from '@/ir/source-edit'; +import { lintColors } from '@/render/lint'; +import { resolveTheme } from '@/render/theme-resolver'; import type { Brand, Deck, Density, Mode, ThemeRef } from '@/ir/schema'; import { createAsset, assetSrc } from '@/storage/asset-store'; import { getDeck, type StoredDeck, updateDeck } from '@/storage/deck-store'; import { DeckRenderer } from '@/render/DeckRenderer'; import { ExportPdf } from '@/render/ExportPdf'; -import { allPalettes, allStyles } from '@/themes/registry'; +import { allPalettes, allStyles, getPalette, getStyle } from '@/themes/registry'; import { AssetsDrawer } from './AssetsDrawer'; import { InsertMenu } from './InsertMenu'; @@ -150,6 +152,17 @@ export function Editor({ deckId }: Props) { [state.styleId, state.paletteId, state.density, state.mode], ); + const lintWarnings = useMemo(() => { + try { + const style = getStyle(state.styleId); + const palette = getPalette(state.paletteId); + const resolved = resolveTheme(theme, style, palette, state.brand); + return lintColors(resolved.colors); + } catch { + return []; + } + }, [theme, state.styleId, state.paletteId, state.brand]); + const result = useMemo(() => { try { const parsed = parseDeck(source, { theme, brand: state.brand }); @@ -316,6 +329,18 @@ export function Editor({ deckId }: Props) { />
+ {lintWarnings.length > 0 ? ( +
+ + ⚠ + + + {lintWarnings.length === 1 + ? `${lintWarnings[0].label} contrast is ${lintWarnings[0].ratio} (needs ${lintWarnings[0].needs})` + : `${lintWarnings.length} contrast issues — adjust palette or brand colors`} + +
+ ) : null} {result.ok ? ( = [ + { + id: 'brand-surface', + label: 'Brand on surface', + fg: colors.brand, + bg: colors.surface, + needs: 3.0, + }, + { + id: 'accent-surface', + label: 'Accent on surface', + fg: colors.accent, + bg: colors.surface, + needs: 3.0, + }, + { + id: 'brand-surface-muted', + label: 'Brand on surface-muted', + fg: colors.brand, + bg: colors.surfaceMuted, + needs: 3.0, + }, + { + id: 'text-muted-surface', + label: 'Muted text on surface', + fg: colors.textMuted, + bg: colors.surface, + needs: 3.0, + }, + ]; + + const warnings: ContrastWarning[] = []; + for (const c of checks) { + const ratio = contrastRatio(c.fg, c.bg); + if (ratio < c.needs) { + warnings.push({ + id: c.id, + label: c.label, + ratio: Math.round(ratio * 10) / 10, + needs: c.needs, + severity: ratio < c.needs * 0.7 ? 'fail' : 'low', + }); + } + } + return warnings; +} diff --git a/tests/render/lint.test.ts b/tests/render/lint.test.ts new file mode 100644 index 0000000..8758322 --- /dev/null +++ b/tests/render/lint.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { lintColors } from '@/render/lint'; + +const baseColors = { + brand: '#2563eb', + accent: '#7c3aed', + surface: '#ffffff', + surfaceMuted: '#fafafa', + text: '#0a0a0a', + textMuted: '#525252', + border: '#e5e5e5', + success: '#16a34a', + warn: '#d97706', + danger: '#dc2626', +}; + +describe('lintColors', () => { + it('returns no warnings for a balanced default palette', () => { + expect(lintColors(baseColors)).toEqual([]); + }); + + it('flags poor brand-on-surface contrast', () => { + const bad = { ...baseColors, brand: '#fafafa' }; + const warnings = lintColors(bad); + expect(warnings.some((w) => w.id === 'brand-surface')).toBe(true); + }); + + it('flags poor muted-text contrast', () => { + const bad = { ...baseColors, textMuted: '#e5e5e5' }; + const warnings = lintColors(bad); + expect(warnings.some((w) => w.id === 'text-muted-surface')).toBe(true); + }); +});