A11y colors (#2261)

* Fix value parsing to avoid errors and display reset correctly

* Add color preview

* Add color contrast checker

* Run formatter

* Satisfy checks

* Small fixes

* Update wording

* Re-order combinations

* Add translation keys

* Use clsx for pass/fail

* Use table elements

* Remove wrapper element

* Use single decimal for contrast

* Move description into popover

* Update css vars to accept preview colors

* Update colors input

* Remove reloadDocument

* Wrap in details summary

* Update popover text
This commit is contained in:
hfcRed 2025-05-26 17:10:44 +02:00 committed by GitHub
parent 6ba4b9d6ff
commit 5cc0be347f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 358 additions and 33 deletions

View File

@ -1,13 +1,31 @@
import clsx from "clsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useDebounce } from "react-use";
import { CUSTOM_CSS_VAR_COLORS } from "~/constants";
import { Button } from "./Button";
import { InfoPopover } from "./InfoPopover";
import { Label } from "./Label";
import { AlertIcon } from "./icons/Alert";
import { CheckmarkIcon } from "./icons/Checkmark";
type CustomColorsRecord = Partial<
Record<(typeof CUSTOM_CSS_VAR_COLORS)[number], string>
>;
type ContrastCombination = [
Exclude<(typeof CUSTOM_CSS_VAR_COLORS)[number], "bg-lightest">,
Exclude<(typeof CUSTOM_CSS_VAR_COLORS)[number], "bg-lightest">,
];
type ContrastArray = {
colors: ContrastCombination;
contrast: {
AA: { failed: boolean; ratio: string };
AAA: { failed: boolean; ratio: string };
};
}[];
export function CustomizedColorsInput({
initialColors,
}: {
@ -18,20 +36,61 @@ export function CustomizedColorsInput({
initialColors ?? {},
);
const [defaultColors, setDefaultColors] = React.useState<
Record<string, string>[]
>([]);
const [contrasts, setContrast] = React.useState<ContrastArray>([]);
useDebounce(
() => {
for (const color in colors) {
const value =
colors[color as (typeof CUSTOM_CSS_VAR_COLORS)[number]] ?? "";
document.documentElement.style.setProperty(`--preview-${color}`, value);
}
setContrast(handleContrast(defaultColors, colors));
},
100,
[colors],
);
React.useEffect(() => {
const colors = CUSTOM_CSS_VAR_COLORS.map((color) => {
return {
[color]: getComputedStyle(document.documentElement).getPropertyValue(
`--${color}`,
),
};
});
setDefaultColors(colors);
return () => {
document.documentElement.removeAttribute("style");
};
}, []);
return (
<div className="w-full">
<Label>{t("custom.colors.title")}</Label>
<input type="hidden" name="css" value={JSON.stringify(colors)} />
<div className="colors__grid">
{CUSTOM_CSS_VAR_COLORS.filter((cssVar) => cssVar !== "bg-lightest").map(
(cssVar) => {
<details className="w-full">
<summary className="colors__summary">
<div>
<span>{t("custom.colors.title")}</span>
</div>
</summary>
<div>
<Label>{t("custom.colors.title")}</Label>
<input type="hidden" name="css" value={JSON.stringify(colors)} />
<div className="colors__container colors__grid">
{CUSTOM_CSS_VAR_COLORS.filter(
(cssVar) => cssVar !== "bg-lightest",
).map((cssVar) => {
return (
<React.Fragment key={cssVar}>
<div>{t(`custom.colors.${cssVar}`)}</div>
<input
type="color"
className="plain"
value={colors[cssVar]}
value={colors[cssVar] ?? "#000000"}
onChange={(e) => {
const extras: Record<string, string> = {};
if (cssVar === "bg-lighter") {
@ -55,16 +114,204 @@ export function CustomizedColorsInput({
if (cssVar === "bg-lighter") {
newColors["bg-lightest"] = undefined;
}
setColors({ ...newColors, [cssVar]: undefined });
setColors({
...newColors,
[cssVar]: defaultColors.find((color) => color[cssVar])?.[
cssVar
],
});
}}
>
{t("actions.reset")}
</Button>
</React.Fragment>
);
},
)}
})}
</div>
<Label labelClassName="stack horizontal sm items-center">
{t("custom.colors.contrast.title")}
<InfoPopover tiny>
<div className="colors__description">
{t("custom.colors.contrast.description")}
</div>
</InfoPopover>
</Label>
<table className="colors__container colors__table">
<thead>
<tr>
<th>{t("custom.colors.contrast.first-color")}</th>
<th>{t("custom.colors.contrast.second-color")}</th>
<th>AA</th>
<th>AAA</th>
</tr>
</thead>
<tbody>
{contrasts.map((contrast) => {
return (
<tr key={contrast.colors.join("-")}>
<td>{t(`custom.colors.${contrast.colors[0]}`)}</td>
<td>{t(`custom.colors.${contrast.colors[1]}`)}</td>
<td
className={clsx(
"colors__contrast",
contrast.contrast.AA.failed ? "fail" : "success",
)}
>
{contrast.contrast.AA.failed ? (
<AlertIcon />
) : (
<CheckmarkIcon />
)}
{contrast.contrast.AA.ratio}
</td>
<td
className={clsx(
"colors__contrast",
contrast.contrast.AAA.failed ? "fail" : "success",
)}
>
{contrast.contrast.AAA.failed ? (
<AlertIcon />
) : (
<CheckmarkIcon />
)}
{contrast.contrast.AAA.ratio}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</details>
);
}
function handleContrast(
defaultColors: Record<string, string>[],
colors: CustomColorsRecord,
) {
/*
Excluded because bg-lightest is not visible to the user,
tho these should be checked as well:
["bg-lightest", "text"],
["bg-lightest", "theme-secondary"],
*/
const combinations: ContrastCombination[] = [
["bg", "text"],
["bg", "text-lighter"],
["bg-darker", "text"],
["bg-darker", "theme"],
["bg-lighter", "text-lighter"],
["bg-lighter", "theme"],
["bg-lighter", "theme-secondary"],
];
const results: ContrastArray = [];
for (const [A, B] of combinations) {
const valueA =
colors[A as (typeof CUSTOM_CSS_VAR_COLORS)[number]] ?? undefined;
const valueB =
colors[B as (typeof CUSTOM_CSS_VAR_COLORS)[number]] ?? undefined;
const colorA = valueA ?? defaultColors.find((color) => color[A])?.[A];
const colorB = valueB ?? defaultColors.find((color) => color[B])?.[B];
if (!colorA || !colorB) continue;
const parsedA = colorA.includes("rgb") ? parseCSSVar(colorA) : colorA;
const parsedB = colorB.includes("rgb") ? parseCSSVar(colorB) : colorB;
results.push({
colors: [A, B],
contrast: checkContrast(parsedA, parsedB),
});
}
return results;
}
function parseCSSVar(cssVar: string) {
const regex = /rgb\((\d+)\s+(\d+)\s+(\d+)(?:\s+\/\s+(\d+%?))?\)/;
const match = cssVar.match(regex);
if (!match) {
return "#000000";
}
const r = Number.parseInt(match[1], 10);
const g = Number.parseInt(match[2], 10);
const b = Number.parseInt(match[3], 10);
let alpha = 255;
if (match[4]) {
const percentage = Number.parseInt(match[4], 10);
alpha = Math.round((percentage / 100) * 255);
}
const toHex = (value: number) => {
const hex = value.toString(16);
return hex.length === 1 ? `0${hex}` : hex;
};
if (match[4]) {
return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(alpha)}`;
}
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
function checkContrast(colorA: string, colorB: string) {
const rgb1 = hexToRgb(colorA);
const rgb2 = hexToRgb(colorB);
const luminanceA = calculateLuminance(rgb1);
const luminanceB = calculateLuminance(rgb2);
const light = Math.max(luminanceA, luminanceB);
const dark = Math.min(luminanceA, luminanceB);
const ratio = (light + 0.05) / (dark + 0.05);
return {
AA: {
failed: ratio < 4.5,
ratio: ratio.toFixed(1),
},
AAA: {
failed: ratio < 7,
ratio: ratio.toFixed(1),
},
};
}
function hexToRgb(hex: string) {
const noHash = hex.replace(/^#/, "");
const r = Number.parseInt(noHash.substring(0, 2), 16);
const g = Number.parseInt(noHash.substring(2, 4), 16);
const b = Number.parseInt(noHash.substring(4, 6), 16);
if (noHash.length === 8) {
const alpha = Number.parseInt(noHash.substring(6, 8), 16) / 255;
return [
Math.round(r * alpha),
Math.round(g * alpha),
Math.round(b * alpha),
];
}
return [r, g, b];
}
function calculateLuminance(rgb: number[]) {
const [r, g, b] = rgb.map((value) => {
const normalized = value / 255;
return normalized <= 0.03928
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

View File

@ -291,7 +291,10 @@ function useCustomizedCSSVars() {
string,
string
>,
).map(([key, value]) => [`--${key}`, value]),
).map(([key, value]) => [
`--${key}`,
`var(--preview-${key}, ${value})`,
]),
) as React.CSSProperties;
}
}

View File

@ -1216,14 +1216,85 @@ abbr[title] {
box-shadow: 0 0 100px inset rgb(255 255 255 / 25%);
}
.colors__grid {
display: grid;
.colors__summary {
padding: var(--s-2) var(--s-3);
border: 2px solid var(--border);
border-radius: var(--rounded-sm);
background-color: var(--bg-input);
font-weight: var(--bold);
font-size: var(--fonts-xs);
& div {
display: inline-flex;
}
& svg {
width: 24px;
color: var(--theme);
position: absolute;
right: 20px;
top: 14px;
}
& + div {
margin-block-start: var(--s-4);
}
}
.colors__container {
width: 100%;
justify-content: center;
font-size: var(--fonts-sm);
font-weight: var(--bold);
grid-template-columns: 3fr 2fr 1fr;
row-gap: var(--s-3);
padding: var(--s-3);
border: 2px solid var(--border);
border-radius: var(--rounded-sm);
background-color: var(--bg-input);
margin-bottom: var(--s-3);
overflow-x: auto;
}
.colors__grid {
display: grid;
justify-content: space-between;
grid-template-columns: repeat(3, max-content);
gap: var(--s-3);
}
.colors__table {
width: 100%;
border-spacing: 0;
text-indent: 0;
text-align: left;
font-size: var(--fonts-xs);
& svg {
width: 1rem;
height: 1rem;
display: inline;
vertical-align: sub;
margin-right: 2px;
}
& td {
padding-block: var(--s-2);
}
& tr:last-child td {
border-bottom: none;
padding-bottom: 0;
}
}
.colors__contrast {
text-wrap-mode: nowrap;
&.fail {
color: var(--theme-error);
}
&.success {
color: var(--theme-success);
}
}
.playwire__img {

View File

@ -1,9 +1,9 @@
html {
--bg: #ebebf0;
--bg-darker: #f8f8f8;
--bg-lighter: rgb(250 250 250);
--bg: var(--preview-bg, #ebebf0);
--bg-darker: var(--preview-bg-darker, #f8f8f8);
--bg-lighter: var(--preview-bg-lighter, rgb(250 250 250));
--bg-lighter-solid: rgb(250 250 250);
--bg-lightest: #fff;
--bg-lightest: var(--preview-bg-lightest, #fff);
--bg-lightest-solid: #fff;
--bg-light-variation: #fff;
--bg-lighter-transparent: hsla(225deg 100% 88% / 50%);
@ -18,9 +18,9 @@ html {
--border-style: 1.5px solid var(--border);
--button-text: rgb(0 0 0 / 85%);
--button-text-transparent: rgb(0 0 0 / 65%);
--text: rgb(0 0 0 / 95%);
--text: var(--preview-text, rgb(0 0 0 / 95%));
--black-text: rgb(0 0 0 / 95%);
--text-lighter: rgb(75 75 75 / 95%);
--text-lighter: var(--preview-text-lighter, rgb(75 75 75 / 95%));
--divider: #f5a2c8;
--theme-error: rgb(199 13 6);
--theme-error-transparent: rgba(199 13 6 / 55%);
@ -35,12 +35,12 @@ html {
--theme-informative-red: #9d0404;
--theme-informative-blue: #007f9c;
--theme-informative-green: #017a0f;
--theme: #f73e8b;
--theme: var(--preview-theme, #f73e8b);
--theme-transparent: #f3a0c386;
--theme-very-transparent: #f3a0c341;
--theme-vibrant: #f73e8b;
--theme-semi-transparent: #ff99c477;
--theme-secondary: rgb(63 58 255);
--theme-secondary: var(--preview-theme-secondary, rgb(63 58 255));
--theme-secondary-transparent: rgb(63 58 255 / 55%);
--input-icon: #6a6a6c;
--backdrop-filter: blur(10px) brightness(95%);
@ -102,13 +102,13 @@ html {
}
html.dark {
--bg: #02011e;
--bg-darker: #0a092d;
--bg-lighter: rgb(169 138 255 / 10%);
--bg: var(--preview-bg, #02011e);
--bg-darker: var(--preview-bg-darker, #0a092d);
--bg-lighter: var(--preview-bg-lighter, rgb(169 138 255 / 10%));
--bg-lighter-solid: #140f34;
--bg-lighter-transparent: rgb(64 67 108 / 50%);
--bg-light-variation: #a98aff30;
--bg-lightest: rgb(169 138 255 / 30%);
--bg-lightest: var(--preview-bg-lightest, rgb(169 138 255 / 30%));
--bg-lightest-solid: #342b62;
--bg-darker-very-transparent: hsla(237.3deg 42.3% 26.6% / 50%);
--bg-darker-transparent: #0a092dce;
@ -120,10 +120,10 @@ html.dark {
--border: rgb(255 255 255 / 10%);
--button-text: rgb(0 0 0 / 85%);
--button-text-transparent: rgb(0 0 0 / 65%);
--text: #e1dede;
--text: var(--preview-text, #e1dede);
--badge-text: var(--text);
--black-text: rgb(0 0 0 / 95%);
--text-lighter: rgb(215 214 255 / 80%);
--text-lighter: var(--preview-text-lighter, rgb(215 214 255 / 80%));
--divider: #ffbedb2f;
--theme-error: rgb(219 70 65);
--theme-error-transparent: rgba(219 70 65 / 55%);
@ -137,12 +137,12 @@ html.dark {
--theme-informative-red: #ff9494;
--theme-informative-blue: #a7efff;
--theme-informative-green: #a2ffad;
--theme: #ffc6de;
--theme: var(--preview-theme, #ffc6de);
--theme-very-transparent: #ffc6de36;
--theme-vibrant: #f391ba;
--theme-semi-transparent: #ff99c477;
--theme-transparent: #ffc6de52;
--theme-secondary: rgb(239 229 83);
--theme-secondary: var(--preview-theme-secondary, rgb(239 229 83));
--theme-secondary-transparent: rgb(239 229 83 / 55%);
--input-icon: #9898a4;
--backdrop-filter: blur(10px) brightness(75%);

View File

@ -265,6 +265,10 @@
"custom.colors.theme": "Theme",
"custom.colors.theme-secondary": "Theme (secondary)",
"custom.colors.chat": "Chat name",
"custom.colors.contrast.title": "Color contrast table",
"custom.colors.contrast.first-color": "First color",
"custom.colors.contrast.second-color": "Second color",
"custom.colors.contrast.description": "This table shows the contrast ratio between two colors listed in the first two columns. \n\nTo make your custom colors accessible to as many people as possible, you should meet a contrast ratio of at least 4.5 (AA) for all listed color combinations. \n\nThough not required, a contrast ratio of at least 7 (AAA) is recommended. \nThank you for making the web a more accessible place!",
"divisions.WEST": "Tentatek",
"divisions.JPN": "Takoroka",