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);
+ });
+});