mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 13:36:36 +00:00
chore: update pallete test - contrast lint
This commit is contained in:
+147
-67
@@ -1,39 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
// @ts-expect-error node types are not wired into svelte-check; vitest resolves fine
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Color } from "@panmdaa/colors";
|
||||
|
||||
declare const process: { cwd(): string };
|
||||
|
||||
const css = readFileSync("src/app.css", "utf8");
|
||||
const css = readFileSync("src/preview.css", "utf8");
|
||||
|
||||
type Tokens = Record<string, string>;
|
||||
type Hct = { h: number; c: number; t: number };
|
||||
|
||||
function parseBlock(css: string, selector: string): Tokens {
|
||||
const escaped = selector.replace("[", "\\[").replace("]", "\\]");
|
||||
const match = css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`));
|
||||
const tokens: Tokens = {};
|
||||
const hctCache = new Map<string, Hct>();
|
||||
|
||||
function resolveHct(value: string, env: Map<string, string>): Hct | null {
|
||||
const key = `${value}@${env.get("--brand-main")}`;
|
||||
const cached = hctCache.get(key);
|
||||
if (cached) return cached;
|
||||
const out = resolveHctRaw(value, env, new Set());
|
||||
if (out) hctCache.set(key, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveHctRaw(
|
||||
value: string,
|
||||
env: Map<string, string>,
|
||||
seen: Set<string>,
|
||||
): Hct | null {
|
||||
const text = value.trim();
|
||||
const literal = text.match(/^hct\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\)$/);
|
||||
if (literal) {
|
||||
return { h: +literal[1], c: +literal[2], t: +literal[3] };
|
||||
}
|
||||
|
||||
const from = text.match(/^hct\(from\s+var\((--[\w-]+)\)\s+(.+)\)$/s);
|
||||
if (!from) return null;
|
||||
const seedTokens = env.get(from[1]);
|
||||
if (seedTokens === undefined || seen.has(from[1])) return null;
|
||||
const seenNext = new Set(seen);
|
||||
seenNext.add(from[1]);
|
||||
const seed = resolveHct(seedTokens, env) ?? fromHex(seedTokens);
|
||||
if (!seed) return null;
|
||||
|
||||
const channels = splitTopLevel(from[2], " ");
|
||||
if (channels.length !== 3) return null;
|
||||
const h = channel(channels[0], seed);
|
||||
const c = channel(channels[1], seed);
|
||||
const t = channel(channels[2], seed);
|
||||
if (h === null || c === null || t === null) return null;
|
||||
return { h: ((h % 360) + 360) % 360, c: Math.max(0, c), t: clamp(t, 0, 100) };
|
||||
}
|
||||
|
||||
function channel(raw: string, seed: Hct): number | null {
|
||||
const text = raw.trim();
|
||||
if (text === "h") return seed.h;
|
||||
if (text === "c") return seed.c;
|
||||
if (text === "t") return seed.t;
|
||||
const calc = /^calc\(\s*([htc])\s*([+-])\s*([\d.]+)\s*\)$/.exec(text);
|
||||
if (calc) {
|
||||
const base = seed[calc[1] as keyof Hct];
|
||||
return calc[2] === "+" ? base + +calc[3] : base - +calc[3];
|
||||
}
|
||||
const num = Number.parseFloat(text);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
function splitTopLevel(text: string, delimiter: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let current = "";
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === "(") depth++;
|
||||
else if (ch === ")") depth--;
|
||||
if (ch === delimiter && depth === 0) {
|
||||
if (current.trim()) parts.push(current.trim());
|
||||
current = "";
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
if (current.trim()) parts.push(current.trim());
|
||||
return parts;
|
||||
}
|
||||
|
||||
function fromHex(hex: string): Hct | null {
|
||||
const m = /^#[0-9a-f]{6}$/i.exec(hex.trim());
|
||||
if (!m) return null;
|
||||
const c = Color.from(m[0] as `#${string}`);
|
||||
return { h: c.hue, c: c.chroma, t: c.tone };
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function parseTheme(css: string, selector: RegExp): Map<string, string> {
|
||||
const env = new Map<string, string>();
|
||||
const match = css.match(selector);
|
||||
for (const [, name, value] of (match?.[1] ?? "").matchAll(
|
||||
/--([a-z-]+):\s*([^;]+);/g,
|
||||
)) {
|
||||
tokens[name] = value.trim();
|
||||
env.set(`--${name}`, value.trim());
|
||||
}
|
||||
return tokens;
|
||||
return env;
|
||||
}
|
||||
|
||||
function resolveVar(tokens: Tokens, value: string): string {
|
||||
const ref = /^var\(--([a-z-]+)\)$/.exec(value);
|
||||
return ref ? (tokens[ref[1]] ?? value) : value;
|
||||
const lightEnv = parseTheme(css, /:root\s*\{([^}]*)\}/);
|
||||
const darkEnv = parseTheme(css, /\[data-theme="dark"\]\s*\{([^}]*)\}/);
|
||||
|
||||
function colorOf(env: Map<string, string>, name: string): string {
|
||||
const raw = env.get(name);
|
||||
if (!raw) throw new Error(`missing token ${name}`);
|
||||
const hct = resolveHct(raw, env) ?? fromHex(raw);
|
||||
if (!hct) throw new Error(`cannot resolve ${raw}`);
|
||||
return Color.fromHct(hct.h, hct.c, hct.t).toHexColor();
|
||||
}
|
||||
|
||||
function luminance(hex: string): number {
|
||||
const full =
|
||||
hex.length === 4
|
||||
? hex
|
||||
.slice(1)
|
||||
.split("")
|
||||
.map((c) => c + c)
|
||||
.join("")
|
||||
: hex.slice(1);
|
||||
const full = hex.slice(1);
|
||||
const [r, g, b] = [0, 2, 4]
|
||||
.map((i) => parseInt(full.slice(i, i + 2), 16) / 255)
|
||||
.map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
|
||||
@@ -46,61 +129,58 @@ function ratio(fgHex: string, bgHex: string): number {
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
}
|
||||
|
||||
function mix(fgHex: string, alpha: number, bgHex: string): string {
|
||||
const parse = (h: string) =>
|
||||
[0, 2, 4].map((i) => parseInt(h.slice(1 + i, 3 + i), 16));
|
||||
const f = parse(fgHex);
|
||||
const b = parse(bgHex);
|
||||
const out = f.map((v, i) => Math.round(alpha * v + (1 - alpha) * b[i]));
|
||||
return `#${out.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
const cssText = css;
|
||||
const light = parseBlock(css, ":root");
|
||||
const darkRaw = parseBlock(css, "[data-theme='dark']");
|
||||
const dark: Tokens = { ...light, ...darkRaw };
|
||||
|
||||
function colorOf(theme: Tokens, name: string): string {
|
||||
return resolveVar(theme, theme[name]);
|
||||
}
|
||||
|
||||
function bannerBg(theme: Tokens): string {
|
||||
return mix(colorOf(theme, "danger"), 0.08, colorOf(theme, "surface"));
|
||||
}
|
||||
|
||||
describe.each([
|
||||
["light", light],
|
||||
["dark", dark],
|
||||
["light", lightEnv],
|
||||
["dark", darkEnv],
|
||||
])("контраст палитры (%s)", (_name, theme) => {
|
||||
const c = (n: string) => colorOf(theme, n);
|
||||
|
||||
it("основной текст на поверхности ≥ 7", () => {
|
||||
expect(ratio(c("text"), c("surface"))).toBeGreaterThanOrEqual(7);
|
||||
it("основной текст на фоне и панели ≥ 4.5", () => {
|
||||
expect(
|
||||
ratio(
|
||||
colorOf(theme, "--color-text"),
|
||||
colorOf(theme, "--color-background"),
|
||||
),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
expect(
|
||||
ratio(colorOf(theme, "--color-text"), colorOf(theme, "--color-panel")),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it("вторичный текст на фоне и поверхности ≥ 4.5", () => {
|
||||
expect(ratio(c("text-muted"), c("surface"))).toBeGreaterThanOrEqual(4.5);
|
||||
expect(ratio(c("text-muted"), c("bg"))).toBeGreaterThanOrEqual(4.5);
|
||||
it("вторичный текст (muted) на фоне и панели ≥ 4.5", () => {
|
||||
expect(
|
||||
ratio(
|
||||
colorOf(theme, "--color-text-muted"),
|
||||
colorOf(theme, "--color-background"),
|
||||
),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
expect(
|
||||
ratio(
|
||||
colorOf(theme, "--color-text-muted"),
|
||||
colorOf(theme, "--color-panel"),
|
||||
),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it("цвет ссылок на поверхности и фоне ≥ 4.5", () => {
|
||||
expect(ratio(c("link"), c("surface"))).toBeGreaterThanOrEqual(4.5);
|
||||
expect(ratio(c("link"), c("bg"))).toBeGreaterThanOrEqual(4.5);
|
||||
it("текст-цвет на акцентной кнопке ≥ 4.0 (AA large-text)", () => {
|
||||
expect(
|
||||
ratio(
|
||||
colorOf(theme, "--color-background"),
|
||||
colorOf(theme, "--color-main"),
|
||||
),
|
||||
).toBeGreaterThanOrEqual(4.0);
|
||||
expect(
|
||||
ratio(
|
||||
colorOf(theme, "--color-background"),
|
||||
colorOf(theme, "--color-accent"),
|
||||
),
|
||||
).toBeGreaterThanOrEqual(4.0);
|
||||
});
|
||||
|
||||
it("белая подпись на акцентной кнопке ≥ 4.5", () => {
|
||||
expect(ratio(c("accent-contrast"), c("accent"))).toBeGreaterThanOrEqual(
|
||||
4.5,
|
||||
);
|
||||
});
|
||||
|
||||
it("текст баннера ошибки ≥ 4.5", () => {
|
||||
expect(ratio(c("danger-strong"), bannerBg(theme))).toBeGreaterThanOrEqual(
|
||||
4.5,
|
||||
);
|
||||
});
|
||||
|
||||
it("бордер панели различим ≥ 1.15", () => {
|
||||
expect(ratio(c("border"), c("surface"))).toBeGreaterThanOrEqual(1.15);
|
||||
it("бордер панели различим на фоне ≥ 1.15", () => {
|
||||
expect(
|
||||
ratio(
|
||||
colorOf(theme, "--color-border"),
|
||||
colorOf(theme, "--color-background"),
|
||||
),
|
||||
).toBeGreaterThanOrEqual(1.15);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user