Add live contrast preview to theme editor for guaranteed readability
Local Build and Deploy / deploy (push) Successful in 1m0s
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:
1 parent
17894db3e9
commit
acc820284b
2 files changed
+170
-45
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string>,
|
||||
suffix = "",
|
||||
bgHint?: string,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
@@ -85,30 +86,16 @@ function colorGrid(
|
||||
>
|
||||
{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 (
|
||||
<div
|
||||
<ColorField
|
||||
key={f.key}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.6rem",
|
||||
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>
|
||||
field={f}
|
||||
value={val}
|
||||
bgValue={bgValue || undefined}
|
||||
name={`${f.key}${suffix}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -218,7 +205,7 @@ export default async function AdminTheme({
|
||||
<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.
|
||||
</p>
|
||||
<div className="admin-card">{colorGrid(COLOR_FIELDS, current)}</div>
|
||||
<div className="admin-card">{colorFields(COLOR_FIELDS, current)}</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
@@ -226,7 +213,7 @@ export default async function AdminTheme({
|
||||
<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.
|
||||
</p>
|
||||
<div className="admin-card">{colorGrid(BUTTON_FIELDS, current)}</div>
|
||||
<div className="admin-card">{colorFields(BUTTON_FIELDS, current)}</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
@@ -234,7 +221,7 @@ export default async function AdminTheme({
|
||||
<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.
|
||||
</p>
|
||||
<div className="admin-card">{colorGrid(GRADIENT_FIELDS, current)}</div>
|
||||
<div className="admin-card">{colorFields(GRADIENT_FIELDS, current)}</div>
|
||||
</section>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
<section className="mt-4">
|
||||
<div className="admin-card">{colorGrid(ADMIN_FIELDS, current)}</div>
|
||||
<div className="admin-card">{colorFields(ADMIN_FIELDS, current)}</div>
|
||||
</section>
|
||||
|
||||
<h2 className="mt-8 text-lg font-extrabold">🌙 Dark mode</h2>
|
||||
@@ -252,15 +239,15 @@ export default async function AdminTheme({
|
||||
</p>
|
||||
<section className="mt-4">
|
||||
<h3 className="admin-section-title">Page & 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 className="mt-6">
|
||||
<h3 className="admin-section-title">Buttons & links</h3>
|
||||
<div className="admin-card">{colorGrid(BUTTON_FIELDS, currentDark, "_dark")}</div>
|
||||
<div className="admin-card">{colorFields(BUTTON_FIELDS, currentDark, "_dark")}</div>
|
||||
</section>
|
||||
<section className="mt-6">
|
||||
<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 className="mt-6">
|
||||
|
||||
Reference in new issue
Block a user