From acc820284b0f0c2aa109d25f7c0ef14072609597 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 13 Jul 2026 20:04:58 +0200 Subject: [PATCH] Add live contrast preview to theme editor for guaranteed readability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New client ColorField component shows a live 'Aa' text preview on the relevant background and a WCAG contrast ratio badge (✓ / ⚠) - Flags low-contrast (<4.5:1) text fields with a red border and a one-click 'Use readable color' fix - Map each text color to the background it sits on (body text -> surface, button text -> button color, navbar text -> navbar, admin text -> canvas) --- src/app/admin/theme/color-field.tsx | 138 ++++++++++++++++++++++++++++ src/app/admin/theme/page.tsx | 77 +++++++--------- 2 files changed, 170 insertions(+), 45 deletions(-) create mode 100644 src/app/admin/theme/color-field.tsx diff --git a/src/app/admin/theme/color-field.tsx b/src/app/admin/theme/color-field.tsx new file mode 100644 index 00000000..872d659f --- /dev/null +++ b/src/app/admin/theme/color-field.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useState } from "react"; + +interface Field { + key: string; + label: string; + def: string; + desc: string; + bgKey?: string; +} + +function hexToRgb(hex: string): readonly [number, number, number] | null { + const m = /^#([0-9a-f]{6})$/i.exec(hex.trim()); + if (!m) return null; + const n = Number.parseInt(m[1], 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +function luminance([r, g, b]: readonly [number, number, number]): number { + const a = [r, g, b].map((v) => { + const c = v / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2]; +} + +function contrastRatio(a: string, b: string): number | null { + const ra = hexToRgb(a); + const rb = hexToRgb(b); + if (!ra || !rb) return null; + const l1 = luminance(ra); + const l2 = luminance(rb); + const hi = Math.max(l1, l2); + const lo = Math.min(l1, l2); + return (hi + 0.05) / (lo + 0.05); +} + +function readableOn(bg: string): string { + const black = contrastRatio("#000000", bg) ?? 0; + const white = contrastRatio("#ffffff", bg) ?? 0; + return white >= black ? "#ffffff" : "#000000"; +} + +export function ColorField({ + field, + value, + bgValue, + name, +}: { + field: Field; + value: string; + bgValue?: string; + name: string; +}) { + const [val, setVal] = useState(value); + const ratio = bgValue ? contrastRatio(val, bgValue) : null; + const poor = ratio != null && ratio < 4.5; + const ok = ratio != null && ratio >= 4.5; + + return ( +
+ setVal(e.target.value)} + style={{ width: 42, height: 36, padding: 0, border: "none", background: "none", cursor: "pointer", flexShrink: 0 }} + /> +
+
+ {field.label} + {ratio != null ? ( + + {ratio.toFixed(1)}:1 {ok ? "✓" : "⚠"} + + ) : null} +
+ + {field.desc} + + {bgValue ? ( +
+ Aa Text preview +
+ ) : null} + {poor && bgValue ? ( + + ) : null} +
+
+ ); +} diff --git a/src/app/admin/theme/page.tsx b/src/app/admin/theme/page.tsx index f5ae142c..7444c580 100644 --- a/src/app/admin/theme/page.tsx +++ b/src/app/admin/theme/page.tsx @@ -2,18 +2,19 @@ import { getTranslations } from "next-intl/server"; import { applyPreset, saveTheme } from "@/actions/admin-theme"; import { siteSettings } from "@/lib/services/site-settings"; import { FONTS, PRESETS } from "@/lib/theme-presets"; +import { ColorField } from "./color-field"; export const dynamic = "force-dynamic"; export const metadata = { title: "Theme" }; -const COLOR_FIELDS: { key: string; label: string; def: string; desc: string }[] = [ +const COLOR_FIELDS: { key: string; label: string; def: string; desc: string; bgKey?: string }[] = [ { key: "color_background", label: "Page background", def: "#f8fafc", desc: "Main page background behind all content" }, { key: "color_surface", label: "Card / surface", def: "#ffffff", desc: "Cards, panels, and elevated surfaces" }, { key: "color_dropdown", label: "Dropdown menu", def: "#ffffff", desc: "Dropdown menus and input backgrounds" }, { key: "color_navbar", label: "Navbar bar", def: "#ffffff", desc: "Top navigation bar background" }, - { key: "color_navbar_text", label: "Navbar text", def: "#1e293b", desc: "Text color in the navigation bar" }, - { key: "color_text", label: "Body text", def: "#0f172a", desc: "Main body text — must contrast with background/surface" }, - { key: "color_text_muted", label: "Muted text", def: "#64748b", desc: "Secondary text, labels, hints — lighter than body text" }, + { key: "color_navbar_text", label: "Navbar text", def: "#1e293b", desc: "Text color in the navigation bar", bgKey: "color_navbar" }, + { key: "color_text", label: "Body text", def: "#0f172a", desc: "Main body text — must contrast with background/surface", bgKey: "color_surface" }, + { key: "color_text_muted", label: "Muted text", def: "#64748b", desc: "Secondary text, labels, hints — lighter than body text", bgKey: "color_surface" }, { key: "color_primary", label: "Primary accent", def: "#f59e0b", desc: "Main brand color, highlights, active elements" }, { key: "color_accent", label: "Secondary accent", def: "#10b981", desc: "Secondary brand color, success-oriented highlights" }, { key: "border_color", label: "Border (golden)", def: "#eeb425", desc: "Borders, dividers, outlines" }, @@ -23,17 +24,17 @@ const COLOR_FIELDS: { key: string; label: string; def: string; desc: string }[] { key: "color_info", label: "Info", def: "#0ea5e9", desc: "Info status (blue)" }, ]; -const BUTTON_FIELDS: { key: string; label: string; def: string; desc: string }[] = [ +const BUTTON_FIELDS: { key: string; label: string; def: string; desc: string; bgKey?: string }[] = [ { key: "button_primary_color", label: "Primary button", def: "#f59e0b", desc: "Main call-to-action button background" }, - { key: "button_text_color", label: "Button text", def: "#1e293b", desc: "Text on primary buttons — must contrast with button color" }, + { key: "button_text_color", label: "Button text", def: "#1e293b", desc: "Text on primary buttons — must contrast with button color", bgKey: "button_primary_color" }, { key: "button_secondary_color", label: "Secondary button", def: "#22c55e", desc: "Secondary action button background" }, - { key: "button_secondary_text_color", label: "Secondary text", def: "#ffffff", desc: "Text on secondary buttons" }, + { key: "button_secondary_text_color", label: "Secondary text", def: "#ffffff", desc: "Text on secondary buttons", bgKey: "button_secondary_color" }, { key: "button_danger_color", label: "Danger button", def: "#ef4444", desc: "Destructive action button (delete, ban)" }, - { key: "button_danger_text_color", label: "Danger text", def: "#ffffff", desc: "Text on danger buttons" }, + { key: "button_danger_text_color", label: "Danger text", def: "#ffffff", desc: "Text on danger buttons", bgKey: "button_danger_color" }, { key: "button_outline_color", label: "Outline border", def: "#eeb425", desc: "Outline button border color" }, - { key: "button_outline_text_color", label: "Outline text", def: "#1a1a2e", desc: "Text on outline buttons" }, - { key: "link_color", label: "Link", def: "#eeb425", desc: "Hyperlink text color" }, - { key: "link_hover_color", label: "Link hover", def: "#cf9d15", desc: "Hyperlink text on hover" }, + { key: "button_outline_text_color", label: "Outline text", def: "#1a1a2e", desc: "Text on outline buttons", bgKey: "color_surface" }, + { key: "link_color", label: "Link", def: "#eeb425", desc: "Hyperlink text color", bgKey: "color_surface" }, + { key: "link_hover_color", label: "Link hover", def: "#cf9d15", desc: "Hyperlink text on hover", bgKey: "color_surface" }, ]; const GRADIENT_FIELDS: { key: string; label: string; def: string; desc: string }[] = [ @@ -47,11 +48,11 @@ const HEADINGS: { key: string; label: string; def: string }[] = [ { key: "size_heading_h3", label: "H3", def: "18" }, ]; -const ADMIN_FIELDS: { key: string; label: string; def: string; desc: string }[] = [ +const ADMIN_FIELDS: { key: string; label: string; def: string; desc: string; bgKey?: string }[] = [ { key: "admin_canvas", label: "Page background", def: "#0f172a", desc: "Main admin page background" }, { key: "admin_surface", label: "Card / panel", def: "#1e293b", desc: "Admin cards, tables, and panel backgrounds" }, - { key: "admin_text", label: "Body text", def: "#f1f5f9", desc: "Main admin text — must contrast with canvas/surface" }, - { key: "admin_text_muted", label: "Muted text", def: "#94a3b8", desc: "Secondary labels, hints, descriptions" }, + { key: "admin_text", label: "Body text", def: "#f1f5f9", desc: "Main admin text — must contrast with canvas/surface", bgKey: "admin_canvas" }, + { key: "admin_text_muted", label: "Muted text", def: "#94a3b8", desc: "Secondary labels, hints, descriptions", bgKey: "admin_canvas" }, { key: "admin_border", label: "Border", def: "#334155", desc: "Table borders, dividers, input outlines" }, { key: "admin_sidebar_bg", label: "Sidebar", def: "#0f172a", desc: "Left navigation sidebar background" }, ]; @@ -67,13 +68,13 @@ interface ThemeField { label: string; def: string; desc: string; + bgKey?: string; } -function colorGrid( +function colorFields( fields: ThemeField[], current: Record, suffix = "", - bgHint?: string, ) { return (
{fields.map((f) => { const val = toHex(current[f.key], f.def); + const bgKey = f.bgKey ? `${f.bgKey}${suffix}` : undefined; + const bgValue = bgKey ? toHex(current[bgKey], "") : undefined; return ( -
- -
- {f.label} - - {f.desc} - -
-
+ field={f} + value={val} + bgValue={bgValue || undefined} + name={`${f.key}${suffix}`} + /> ); })}
@@ -218,7 +205,7 @@ export default async function AdminTheme({

Backgrounds, surfaces, and text colors. Text colors must contrast with their backgrounds for readability.

-
{colorGrid(COLOR_FIELDS, current)}
+
{colorFields(COLOR_FIELDS, current)}
@@ -226,7 +213,7 @@ export default async function AdminTheme({

Button backgrounds, text on buttons, and link colors. Button text must contrast with its button background.

-
{colorGrid(BUTTON_FIELDS, current)}
+
{colorFields(BUTTON_FIELDS, current)}
@@ -234,7 +221,7 @@ export default async function AdminTheme({

Card header and hero section gradient. These blend from start → end across headings and banners.

-
{colorGrid(GRADIENT_FIELDS, current)}
+
{colorFields(GRADIENT_FIELDS, current)}

🛠 Admin panel (HK)

@@ -243,7 +230,7 @@ export default async function AdminTheme({ use auto-derived defaults.

-
{colorGrid(ADMIN_FIELDS, current)}
+
{colorFields(ADMIN_FIELDS, current)}

🌙 Dark mode

@@ -252,15 +239,15 @@ export default async function AdminTheme({

Page & text colors

-
{colorGrid(COLOR_FIELDS, currentDark, "_dark")}
+
{colorFields(COLOR_FIELDS, currentDark, "_dark")}

Buttons & links

-
{colorGrid(BUTTON_FIELDS, currentDark, "_dark")}
+
{colorFields(BUTTON_FIELDS, currentDark, "_dark")}

Gradients

-
{colorGrid(GRADIENT_FIELDS, currentDark, "_dark")}
+
{colorFields(GRADIENT_FIELDS, currentDark, "_dark")}