Add live contrast preview to theme editor for guaranteed readability
Local Build and Deploy / deploy (push) Successful in 1m0s

- 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)
This commit is contained in:
openhands committed 2026-07-13 20:04:58 +02:00
1 parent 17894db3e9
commit acc820284b
2 files changed
+170 -45

No files matched your search

+138
View File
@@ -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 (
<div
style={{
display: "flex",
gap: "0.6rem",
padding: "0.5rem",
borderRadius: 8,
border: poor ? "1px solid #ef4444" : "1px solid transparent",
background: poor ? "rgba(239,68,68,0.08)" : "transparent",
}}
>
<input
type="color"
name={name}
value={val}
onChange={(e) => setVal(e.target.value)}
style={{ width: 42, height: 36, padding: 0, border: "none", background: "none", cursor: "pointer", flexShrink: 0 }}
/>
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: "0.5rem" }}>
<span style={{ fontSize: "0.82rem", fontWeight: 600, lineHeight: 1.2 }}>{field.label}</span>
{ratio != null ? (
<span style={{ fontSize: "0.7rem", fontWeight: 700, color: ok ? "#22c55e" : "#ef4444", whiteSpace: "nowrap" }}>
{ratio.toFixed(1)}:1 {ok ? "✓" : "⚠"}
</span>
) : null}
</div>
<span
style={{
fontSize: "0.7rem",
color: "var(--admin-text-muted)",
lineHeight: 1.3,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{field.desc}
</span>
{bgValue ? (
<div
style={{
marginTop: 4,
padding: "2px 8px",
borderRadius: 4,
background: bgValue,
color: val,
fontSize: "0.75rem",
fontWeight: 600,
}}
>
Aa Text preview
</div>
) : null}
{poor && bgValue ? (
<button
type="button"
onClick={() => setVal(readableOn(bgValue))}
style={{
marginTop: 4,
alignSelf: "flex-start",
fontSize: "0.7rem",
fontWeight: 600,
color: "var(--admin-accent)",
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
}}
>
Use readable color →
</button>
) : null}
</div>
</div>
);
}
+32 -45
View File
@@ -2,18 +2,19 @@ import { getTranslations } from "next-intl/server";
import { applyPreset, saveTheme } from "@/actions/admin-theme"; import { applyPreset, saveTheme } from "@/actions/admin-theme";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { FONTS, PRESETS } from "@/lib/theme-presets"; import { FONTS, PRESETS } from "@/lib/theme-presets";
import { ColorField } from "./color-field";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const metadata = { title: "Theme" }; 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_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_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_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", 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_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" }, { 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" }, { 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_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: "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" }, { 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)" }, { 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_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_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_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_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: "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" }, { 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" }, { 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 }[] = [ 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" }, { 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_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_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", 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" }, { 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_border", label: "Border", def: "#334155", desc: "Table borders, dividers, input outlines" },
{ key: "admin_sidebar_bg", label: "Sidebar", def: "#0f172a", desc: "Left navigation sidebar background" }, { key: "admin_sidebar_bg", label: "Sidebar", def: "#0f172a", desc: "Left navigation sidebar background" },
]; ];
@@ -67,13 +68,13 @@ interface ThemeField {
label: string; label: string;
def: string; def: string;
desc: string; desc: string;
bgKey?: string;
} }
function colorGrid( function colorFields(
fields: ThemeField[], fields: ThemeField[],
current: Record<string, string>, current: Record<string, string>,
suffix = "", suffix = "",
bgHint?: string,
) { ) {
return ( return (
<div <div
@@ -85,30 +86,16 @@ function colorGrid(
> >
{fields.map((f) => { {fields.map((f) => {
const val = toHex(current[f.key], f.def); const val = toHex(current[f.key], f.def);
const bgKey = f.bgKey ? `${f.bgKey}${suffix}` : undefined;
const bgValue = bgKey ? toHex(current[bgKey], "") : undefined;
return ( return (
<div <ColorField
key={f.key} key={f.key}
style={{ field={f}
display: "flex", value={val}
alignItems: "center", bgValue={bgValue || undefined}
gap: "0.6rem", name={`${f.key}${suffix}`}
padding: "0.4rem 0.5rem", />
borderRadius: 6,
}}
>
<input
type="color"
name={`${f.key}${suffix}`}
defaultValue={val}
style={{ width: 40, height: 32, padding: 0, border: "none", background: "none", cursor: "pointer", flexShrink: 0 }}
/>
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
<span style={{ fontSize: "0.82rem", fontWeight: 600, lineHeight: 1.3 }}>{f.label}</span>
<span style={{ fontSize: "0.7rem", color: "var(--admin-text-muted)", lineHeight: 1.3, display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>
{f.desc}
</span>
</div>
</div>
); );
})} })}
</div> </div>
@@ -218,7 +205,7 @@ export default async function AdminTheme({
<p className="text-xs text-[var(--admin-text-muted)] mb-2"> <p className="text-xs text-[var(--admin-text-muted)] mb-2">
Backgrounds, surfaces, and text colors. Text colors must contrast with their backgrounds for readability. Backgrounds, surfaces, and text colors. Text colors must contrast with their backgrounds for readability.
</p> </p>
<div className="admin-card">{colorGrid(COLOR_FIELDS, current)}</div> <div className="admin-card">{colorFields(COLOR_FIELDS, current)}</div>
</section> </section>
<section className="mt-6"> <section className="mt-6">
@@ -226,7 +213,7 @@ export default async function AdminTheme({
<p className="text-xs text-[var(--admin-text-muted)] mb-2"> <p className="text-xs text-[var(--admin-text-muted)] mb-2">
Button backgrounds, text on buttons, and link colors. Button text must contrast with its button background. Button backgrounds, text on buttons, and link colors. Button text must contrast with its button background.
</p> </p>
<div className="admin-card">{colorGrid(BUTTON_FIELDS, current)}</div> <div className="admin-card">{colorFields(BUTTON_FIELDS, current)}</div>
</section> </section>
<section className="mt-6"> <section className="mt-6">
@@ -234,7 +221,7 @@ export default async function AdminTheme({
<p className="text-xs text-[var(--admin-text-muted)] mb-2"> <p className="text-xs text-[var(--admin-text-muted)] mb-2">
Card header and hero section gradient. These blend from start → end across headings and banners. Card header and hero section gradient. These blend from start → end across headings and banners.
</p> </p>
<div className="admin-card">{colorGrid(GRADIENT_FIELDS, current)}</div> <div className="admin-card">{colorFields(GRADIENT_FIELDS, current)}</div>
</section> </section>
<h2 className="mt-8 text-lg font-extrabold">🛠 Admin panel (HK)</h2> <h2 className="mt-8 text-lg font-extrabold">🛠 Admin panel (HK)</h2>
@@ -243,7 +230,7 @@ export default async function AdminTheme({
use auto-derived defaults. use auto-derived defaults.
</p> </p>
<section className="mt-4"> <section className="mt-4">
<div className="admin-card">{colorGrid(ADMIN_FIELDS, current)}</div> <div className="admin-card">{colorFields(ADMIN_FIELDS, current)}</div>
</section> </section>
<h2 className="mt-8 text-lg font-extrabold">🌙 Dark mode</h2> <h2 className="mt-8 text-lg font-extrabold">🌙 Dark mode</h2>
@@ -252,15 +239,15 @@ export default async function AdminTheme({
</p> </p>
<section className="mt-4"> <section className="mt-4">
<h3 className="admin-section-title">Page &amp; text colors</h3> <h3 className="admin-section-title">Page &amp; text colors</h3>
<div className="admin-card">{colorGrid(COLOR_FIELDS, currentDark, "_dark")}</div> <div className="admin-card">{colorFields(COLOR_FIELDS, currentDark, "_dark")}</div>
</section> </section>
<section className="mt-6"> <section className="mt-6">
<h3 className="admin-section-title">Buttons &amp; links</h3> <h3 className="admin-section-title">Buttons &amp; links</h3>
<div className="admin-card">{colorGrid(BUTTON_FIELDS, currentDark, "_dark")}</div> <div className="admin-card">{colorFields(BUTTON_FIELDS, currentDark, "_dark")}</div>
</section> </section>
<section className="mt-6"> <section className="mt-6">
<h3 className="admin-section-title">Gradients</h3> <h3 className="admin-section-title">Gradients</h3>
<div className="admin-card">{colorGrid(GRADIENT_FIELDS, currentDark, "_dark")}</div> <div className="admin-card">{colorFields(GRADIENT_FIELDS, currentDark, "_dark")}</div>
</section> </section>
<section className="mt-6"> <section className="mt-6">