fix: enforce semantic contrast across admin
Local Build and Deploy / deploy (push) Successful in 44s
Local Build and Deploy / deploy (push) Successful in 44s
This commit is contained in:
1 parent
45c8178cfa
commit
f0b4bc1630
86 files changed
+438
-338
No files matched your search
@@ -0,0 +1,34 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOTS = ["src/app/admin", "src/components/admin"];
|
||||
const GRAPHICAL_ALLOWLIST = [
|
||||
"src/app/admin/favicon/favicon-generator.tsx",
|
||||
"src/app/admin/import/clone/import-clone-client.tsx",
|
||||
"src/app/admin/import/furni/import-furni-client.tsx",
|
||||
"src/components/admin/catalog/items-shop-preview.tsx",
|
||||
];
|
||||
|
||||
function sourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
return entry.isDirectory() ? sourceFiles(path) : /\.(?:ts|tsx)$/.test(entry.name) ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
describe("admin theme source audit", () => {
|
||||
it("keeps ordinary admin text theme-aware", () => {
|
||||
const violations: string[] = [];
|
||||
for (const root of ROOTS) {
|
||||
for (const file of sourceFiles(root)) {
|
||||
const normalized = relative(process.cwd(), file).replaceAll("\\", "/");
|
||||
if (GRAPHICAL_ALLOWLIST.includes(normalized)) continue;
|
||||
const source = readFileSync(file, "utf8");
|
||||
const risky = source.match(/(?:text-(?:gray|slate)-(?:400|500|600|700|800|900)|text-white(?:\/\d+)?|\)\]0)/g);
|
||||
if (risky) violations.push(`${normalized}: ${[...new Set(risky)].join(", ")}`);
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -86,6 +86,24 @@ describe("derivePublicForegrounds", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("covers admin hierarchy and every semantic status", () => {
|
||||
const required = [
|
||||
"subtle", "disabled", "sidebar", "input",
|
||||
"success-text", "success-solid", "warning-text", "warning-solid",
|
||||
"error-text", "error-solid", "info-text", "info-solid",
|
||||
];
|
||||
for (const preset of Object.values(PRESETS)) {
|
||||
for (const palette of [preset.light, preset.dark]) {
|
||||
const result = derivePublicForegrounds(palette);
|
||||
for (const name of required) {
|
||||
const pair = result.pairs.find((candidate) => candidate.name === name);
|
||||
expect(pair, `missing ${name}`).toBeDefined();
|
||||
expect(contrastRatio(pair!.foreground, pair!.background), name).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("repairs hostile white-on-white custom colors", () => {
|
||||
const result = derivePublicForegrounds({
|
||||
color_background: "#ffffff", color_surface: "#ffffff", color_dropdown: "#ffffff",
|
||||
|
||||
@@ -18,6 +18,10 @@ export function derivePublicForegrounds(c: PublicThemeColors) {
|
||||
make("body", value("color_text", "#0f172a"), background),
|
||||
make("surface", value("color_text", "#0f172a"), surface),
|
||||
make("muted", value("color_text_muted", "#64748b"), surface),
|
||||
make("subtle", value("color_text_muted", "#64748b"), surface),
|
||||
make("disabled", value("color_text_muted", "#64748b"), surface),
|
||||
make("sidebar", value("color_navbar_text", "#1e293b"), navbar),
|
||||
make("input", value("color_text", "#0f172a"), dropdown),
|
||||
make("link", value("link_color", primary), surface),
|
||||
make("link-hover", value("link_hover_color", primary), surface),
|
||||
make("navbar", value("color_navbar_text", "#1e293b"), navbar),
|
||||
@@ -31,6 +35,13 @@ export function derivePublicForegrounds(c: PublicThemeColors) {
|
||||
make("pill", value("color_text", "#0f172a"), surface),
|
||||
make("badge", value("button_text_color", "#1e293b"), primary),
|
||||
make("overlay", "#ffffff", "#111827"),
|
||||
...(["success", "warning", "error", "info"] as const).flatMap((status) => {
|
||||
const statusColor = value(`color_${status}`, "#0f172a");
|
||||
return [
|
||||
make(`${status}-text`, statusColor, surface),
|
||||
make(`${status}-solid`, "#ffffff", statusColor),
|
||||
];
|
||||
}),
|
||||
];
|
||||
return { pairs, byName: Object.fromEntries(pairs.map((pair) => [pair.name, pair.foreground])) };
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ export function themePaletteCss(selector: string, palette: ThemePalette): string
|
||||
`--color-text-muted-readable:${readableColor(palette.color_text_muted, [palette.color_background, palette.color_surface])};`,
|
||||
`--color-primary-readable:${readableColor(palette.color_primary, [palette.color_background, palette.color_surface])};`,
|
||||
`--color-accent-readable:${readableColor(palette.color_accent, [palette.color_background, palette.color_surface])};`,
|
||||
`--color-primary-foreground-readable:${semantic.byName.primary};`,
|
||||
`--color-accent-foreground-readable:${semantic.byName.accent};`,
|
||||
`--color-navbar-text-readable:${semantic.byName.navbar};`,
|
||||
`--button-text-color-readable:${semantic.byName["button-primary"]};`,
|
||||
`--button-secondary-text-color-readable:${semantic.byName["button-secondary"]};`,
|
||||
@@ -39,6 +41,18 @@ export function themePaletteCss(selector: string, palette: ThemePalette): string
|
||||
`--color-pill-text-readable:${semantic.byName.pill};`,
|
||||
`--color-badge-text-readable:${semantic.byName.badge};`,
|
||||
`--color-overlay-text-readable:${semantic.byName.overlay};`,
|
||||
`--color-text-subtle-readable:${semantic.byName.subtle};`,
|
||||
`--color-text-disabled-readable:${semantic.byName.disabled};`,
|
||||
`--admin-sidebar-background:${palette.color_navbar};`,
|
||||
`--admin-sidebar-text-readable:${semantic.byName.sidebar};`,
|
||||
`--admin-sidebar-muted-readable:${semantic.byName.sidebar};`,
|
||||
`--admin-input-text-readable:${semantic.byName.input};`,
|
||||
...(["success", "warning", "error", "info"] as const).flatMap((status) => [
|
||||
`--color-${status}-text-readable:${semantic.byName[`${status}-text`]};`,
|
||||
`--color-${status}-foreground-readable:${semantic.byName[`${status}-solid`]};`,
|
||||
`--color-${status}-subtle:color-mix(in srgb, ${palette[`color_${status}`]} 14%, ${palette.color_surface});`,
|
||||
`--color-${status}-border:color-mix(in srgb, ${palette[`color_${status}`]} 38%, transparent);`,
|
||||
]),
|
||||
);
|
||||
return `${selector}{${declarations.join("")}}`;
|
||||
}
|
||||
Reference in new issue
Block a user