fix: enforce public contrast and audit rank errors
This commit is contained in:
1 parent
e1381c0f40
commit
b695a33ead
12 files changed
+184
-29
No files matched your search
+10
-24
@@ -2,6 +2,7 @@ import { getTranslations } from "next-intl/server";
|
|||||||
import { ScrollText } from "lucide-react";
|
import { ScrollText } from "lucide-react";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { buildStaffActivityWhere } from "@/lib/admin/log-filters";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ function fromDate(d: Date | null | undefined): string {
|
|||||||
export default async function AdminStaffActivities({
|
export default async function AdminStaffActivities({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ q?: string; page?: string; staffId?: string; action?: string }>;
|
searchParams: Promise<{ q?: string; page?: string; staffId?: string; action?: string; authorization?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const t = await getTranslations("pages.admin.logs");
|
const t = await getTranslations("pages.admin.logs");
|
||||||
|
|
||||||
@@ -23,27 +24,8 @@ export default async function AdminStaffActivities({
|
|||||||
const page = Math.max(1, Number(sp.page ?? "1") || 1);
|
const page = Math.max(1, Number(sp.page ?? "1") || 1);
|
||||||
const staffId = sp.staffId ? Number(sp.staffId) : null;
|
const staffId = sp.staffId ? Number(sp.staffId) : null;
|
||||||
const action = sp.action ?? null;
|
const action = sp.action ?? null;
|
||||||
|
const authorizationOnly = sp.authorization === "1";
|
||||||
let whereClause = {};
|
const whereClause = buildStaffActivityWhere({ q, staffId, action, authorizationOnly });
|
||||||
if (q) {
|
|
||||||
whereClause = {
|
|
||||||
OR: [{ action: { contains: q } }, { description: { contains: q } }, { ipAddress: { contains: q } }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (staffId) {
|
|
||||||
whereClause = {
|
|
||||||
...whereClause,
|
|
||||||
userId: BigInt(staffId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action) {
|
|
||||||
whereClause = {
|
|
||||||
...whereClause,
|
|
||||||
action: { contains: action },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const activities = await prisma.staffActivities
|
const activities = await prisma.staffActivities
|
||||||
.findMany({
|
.findMany({
|
||||||
@@ -111,6 +93,10 @@ export default async function AdminStaffActivities({
|
|||||||
placeholder={t("searchPlaceholder")}
|
placeholder={t("searchPlaceholder")}
|
||||||
className="flex-1 min-w-[200px]"
|
className="flex-1 min-w-[200px]"
|
||||||
/>
|
/>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" name="authorization" value="1" defaultChecked={authorizationOnly} />
|
||||||
|
Authorization errors
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
name="staffId"
|
name="staffId"
|
||||||
defaultValue={staffId?.toString()}
|
defaultValue={staffId?.toString()}
|
||||||
@@ -127,7 +113,7 @@ export default async function AdminStaffActivities({
|
|||||||
<button type="submit" className="btn btn-primary">
|
<button type="submit" className="btn btn-primary">
|
||||||
{t("filter")}
|
{t("filter")}
|
||||||
</button>
|
</button>
|
||||||
{(q || staffId || action) && (
|
{(q || staffId || action || authorizationOnly) && (
|
||||||
<a href="/admin/logs" className="btn btn-outline">
|
<a href="/admin/logs" className="btn btn-outline">
|
||||||
{t("clearFilters")}
|
{t("clearFilters")}
|
||||||
</a>
|
</a>
|
||||||
@@ -195,7 +181,7 @@ export default async function AdminStaffActivities({
|
|||||||
<>
|
<>
|
||||||
{" · "}
|
{" · "}
|
||||||
<a
|
<a
|
||||||
href={`/admin/logs?q=${encodeURIComponent(q)}&staffId=${staffId || ""}&action=${action || ""}&page=${page + 1}`}
|
href={`/admin/logs?q=${encodeURIComponent(q)}&staffId=${staffId || ""}&action=${action || ""}&authorization=${authorizationOnly ? "1" : ""}&page=${page + 1}`}
|
||||||
>
|
>
|
||||||
{t("next")} →
|
{t("next")} →
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
+9
-2
@@ -56,6 +56,7 @@
|
|||||||
--button-danger-hover-color: #dc2626;
|
--button-danger-hover-color: #dc2626;
|
||||||
--button-outline-color: #eeb425;
|
--button-outline-color: #eeb425;
|
||||||
--button-outline-text-color: #1a1a2e;
|
--button-outline-text-color: #1a1a2e;
|
||||||
|
--button-outline-text-color-readable: #1a1a2e;
|
||||||
--button-outline-hover-color: #d4a01f;
|
--button-outline-hover-color: #d4a01f;
|
||||||
--border-radius: 12px;
|
--border-radius: 12px;
|
||||||
--border-color: #eeb425;
|
--border-color: #eeb425;
|
||||||
@@ -73,6 +74,11 @@
|
|||||||
--link-hover-color: #d4a01f;
|
--link-hover-color: #d4a01f;
|
||||||
--link-color-readable: #000000;
|
--link-color-readable: #000000;
|
||||||
--link-hover-color-readable: #000000;
|
--link-hover-color-readable: #000000;
|
||||||
|
--color-surface-text-readable: #0f172a;
|
||||||
|
--color-dropdown-text-readable: #0f172a;
|
||||||
|
--color-pill-text-readable: #0f172a;
|
||||||
|
--color-badge-text-readable: #000000;
|
||||||
|
--color-overlay-text-readable: #ffffff;
|
||||||
--input-border-color: #d1d5db;
|
--input-border-color: #d1d5db;
|
||||||
--input-focus-color: #eeb425;
|
--input-focus-color: #eeb425;
|
||||||
--navbar-height: 56px;
|
--navbar-height: 56px;
|
||||||
@@ -164,7 +170,7 @@ body {
|
|||||||
}
|
}
|
||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
@apply block py-2.5 px-4 text-sm font-semibold transition-all duration-100 rounded-lg mx-1;
|
@apply block py-2.5 px-4 text-sm font-semibold transition-all duration-100 rounded-lg mx-1;
|
||||||
color: var(--color-text-readable, var(--color-text));
|
color: var(--color-dropdown-text-readable, var(--color-text-readable));
|
||||||
}
|
}
|
||||||
.dropdown-item:hover {
|
.dropdown-item:hover {
|
||||||
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
@@ -324,7 +330,7 @@ a:hover {
|
|||||||
.btn-outline {
|
.btn-outline {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-color: var(--button-outline-color);
|
border-color: var(--button-outline-color);
|
||||||
color: #8a6a05;
|
color: var(--button-outline-text-color-readable, var(--button-outline-text-color));
|
||||||
}
|
}
|
||||||
input,
|
input,
|
||||||
select,
|
select,
|
||||||
@@ -385,6 +391,7 @@ tbody tr:hover {
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
||||||
|
color: var(--color-pill-text-readable, var(--color-text-readable));
|
||||||
}
|
}
|
||||||
.currency .coin {
|
.currency .coin {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { FONTS } from "@/lib/theme-presets";
|
import { FONTS } from "@/lib/theme-presets";
|
||||||
import { readableColor } from "@/lib/theme-contrast";
|
import { derivePublicForegrounds, readableColor } from "@/lib/theme-contrast";
|
||||||
|
|
||||||
// Injects the DB-driven CSS custom properties into :root, exactly like
|
// Injects the DB-driven CSS custom properties into :root, exactly like
|
||||||
// AtomCMS's app.blade.php. Falls back to the atom defaults when no DB. Covers
|
// AtomCMS's app.blade.php. Falls back to the atom defaults when no DB. Covers
|
||||||
@@ -109,6 +109,18 @@ export async function ThemeVars() {
|
|||||||
[safe(primary, "#f59e0b")],
|
[safe(primary, "#f59e0b")],
|
||||||
);
|
);
|
||||||
const readableAccentForeground = readableColor("#ffffff", [safe(accent, "#10b981")]);
|
const readableAccentForeground = readableColor("#ffffff", [safe(accent, "#10b981")]);
|
||||||
|
const semantic = derivePublicForegrounds({
|
||||||
|
color_background: safeBackground, color_surface: safeSurface,
|
||||||
|
color_dropdown: safe(dropdown, "#ffffff"), color_navbar: safe(navbar, "#ffffff"),
|
||||||
|
color_navbar_text: safe(navbarText, "#1e293b"), color_text: safe(text, "#0f172a"),
|
||||||
|
color_text_muted: safe(textMuted, "#64748b"), color_primary: safe(primary, "#f59e0b"),
|
||||||
|
color_accent: safe(accent, "#10b981"), button_primary_color: safe(buttonColor, "#f59e0b"),
|
||||||
|
button_text_color: safe(buttonText, "#1e293b"), button_secondary_color: safe(buttonSecondary, "#22c55e"),
|
||||||
|
button_secondary_text_color: safe(buttonSecondaryText, "#ffffff"), button_danger_color: safe(buttonDanger, "#ef4444"),
|
||||||
|
button_danger_text_color: safe(buttonDangerText, "#ffffff"), button_outline_color: safe(buttonOutline, "#eeb425"),
|
||||||
|
button_outline_text_color: safe(buttonOutlineText, "#1a1a2e"), link_color: safe(linkColor, "#eeb425"),
|
||||||
|
link_hover_color: safe(linkHover, "#cf9d15"),
|
||||||
|
});
|
||||||
|
|
||||||
const font = FONTS[fontKey ?? "nunito"] ?? FONTS.nunito;
|
const font = FONTS[fontKey ?? "nunito"] ?? FONTS.nunito;
|
||||||
|
|
||||||
@@ -151,6 +163,12 @@ export async function ThemeVars() {
|
|||||||
--button-danger-text-color-readable:${readableDangerText};
|
--button-danger-text-color-readable:${readableDangerText};
|
||||||
--link-color-readable:${readableLink};
|
--link-color-readable:${readableLink};
|
||||||
--link-hover-color-readable:${readableLinkHover};
|
--link-hover-color-readable:${readableLinkHover};
|
||||||
|
--color-surface-text-readable:${semantic.byName.surface};
|
||||||
|
--color-dropdown-text-readable:${semantic.byName.dropdown};
|
||||||
|
--button-outline-text-color-readable:${semantic.byName["button-outline"]};
|
||||||
|
--color-pill-text-readable:${semantic.byName.pill};
|
||||||
|
--color-badge-text-readable:${semantic.byName.badge};
|
||||||
|
--color-overlay-text-readable:${semantic.byName.overlay};
|
||||||
--border-radius:${px(borderRadius, "12")}px;
|
--border-radius:${px(borderRadius, "12")}px;
|
||||||
--font-family:${font.stack};
|
--font-family:${font.stack};
|
||||||
--size-heading-h1:${px(h1, "30")}px;
|
--size-heading-h1:${px(h1, "30")}px;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export interface AuthorizationEvent {
|
||||||
|
kind: "permission.denied" | "permission.load_error";
|
||||||
|
userId: number;
|
||||||
|
username?: string;
|
||||||
|
rank: number;
|
||||||
|
permission?: string;
|
||||||
|
source: string;
|
||||||
|
reason: string;
|
||||||
|
error?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clean = (value: string) => value.replace(/(token|password|secret|cookie|authorization|select|insert|update|delete)[^\s]*/gi, "[REDACTED]").slice(0, 160);
|
||||||
|
|
||||||
|
export function authorizationActivity(event: AuthorizationEvent) {
|
||||||
|
const description = [
|
||||||
|
`user=${clean(event.username ?? String(event.userId))}`,
|
||||||
|
`rank=${event.rank}`,
|
||||||
|
event.permission ? `permission=${clean(event.permission)}` : null,
|
||||||
|
`source=${clean(event.source)}`,
|
||||||
|
`reason=${clean(event.reason)}`,
|
||||||
|
].filter(Boolean).join("; ");
|
||||||
|
return { staffId: event.userId, action: event.kind, description, targetType: "user", targetId: event.userId };
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { authorizationActivity } from "@/lib/admin/authorization-event";
|
||||||
|
|
||||||
|
describe("authorizationActivity", () => {
|
||||||
|
it("creates a safe rank-aware denial record", () => {
|
||||||
|
const record = authorizationActivity({ kind: "permission.denied", userId: 42, username: "admin", rank: 11, permission: "admin.logs.view", source: "/admin/logs", reason: "missing permission" });
|
||||||
|
expect(record.action).toBe("permission.denied");
|
||||||
|
expect(record.description).toContain("rank=11");
|
||||||
|
expect(record.description).toContain("permission=admin.logs.view");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts secrets and technical details", () => {
|
||||||
|
const record = authorizationActivity({ kind: "permission.load_error", userId: 42, rank: 11, source: "permissions", reason: "token=abc password=hunter2 SELECT * FROM users" });
|
||||||
|
expect(record.description).not.toMatch(/abc|hunter2|SELECT/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { logServerError } from "@/lib/server-log";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import { authorizationActivity, type AuthorizationEvent } from "@/lib/admin/authorization-event";
|
||||||
|
|
||||||
|
export async function logAuthorizationEvent(event: AuthorizationEvent): Promise<void> {
|
||||||
|
const correlationId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
await logStaffActivity({ ...authorizationActivity(event), description: `${authorizationActivity(event).description}; correlation=${correlationId}` });
|
||||||
|
if (event.error) logServerError(event.kind, event.error, { correlationId, userId: event.userId, rank: event.rank, permission: event.permission ?? null, source: event.source });
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildStaffActivityWhere } from "@/lib/admin/log-filters";
|
||||||
|
|
||||||
|
describe("buildStaffActivityWhere", () => {
|
||||||
|
it("filters authorization events by prefix", () => {
|
||||||
|
expect(buildStaffActivityWhere({ authorizationOnly: true })).toEqual({ action: { startsWith: "permission." } });
|
||||||
|
});
|
||||||
|
it("combines staff and search filters", () => {
|
||||||
|
const result = buildStaffActivityWhere({ q: "rank", staffId: 11, authorizationOnly: true });
|
||||||
|
expect(result).toMatchObject({ userId: 11n, action: { startsWith: "permission." } });
|
||||||
|
expect(result.OR).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
export interface StaffActivityFilters { q?: string; staffId?: number | null; action?: string | null; authorizationOnly?: boolean }
|
||||||
|
|
||||||
|
export function buildStaffActivityWhere(filters: StaffActivityFilters): Prisma.StaffActivitiesWhereInput {
|
||||||
|
const where: Prisma.StaffActivitiesWhereInput = {};
|
||||||
|
if (filters.q?.trim()) where.OR = [
|
||||||
|
{ action: { contains: filters.q.trim() } },
|
||||||
|
{ description: { contains: filters.q.trim() } },
|
||||||
|
{ ipAddress: { contains: filters.q.trim() } },
|
||||||
|
];
|
||||||
|
if (filters.staffId) where.userId = BigInt(filters.staffId);
|
||||||
|
if (filters.authorizationOnly) where.action = { startsWith: "permission." };
|
||||||
|
else if (filters.action) where.action = { contains: filters.action };
|
||||||
|
return where;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { cache } from 'react'
|
|||||||
import { auth } from './auth'
|
import { auth } from './auth'
|
||||||
import { sessionUserId } from './auth/session-user'
|
import { sessionUserId } from './auth/session-user'
|
||||||
import { prisma } from './prisma'
|
import { prisma } from './prisma'
|
||||||
|
import { logAuthorizationEvent } from './admin/authorization-events'
|
||||||
|
|
||||||
// Re-export PERMS from the standalone file (safe for client components)
|
// Re-export PERMS from the standalone file (safe for client components)
|
||||||
export { PERMS } from './permission-slugs'
|
export { PERMS } from './permission-slugs'
|
||||||
@@ -82,7 +83,8 @@ export const loadUserPermissions = cache(async function loadUserPermissions(
|
|||||||
hasAll: (...perms: string[]) => perms.every((p) => slugs.has(p)),
|
hasAll: (...perms: string[]) => perms.every((p) => slugs.has(p)),
|
||||||
isSuperAdmin: false,
|
isSuperAdmin: false,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
await logAuthorizationEvent({ kind: 'permission.load_error', userId, rank, source: 'loadUserPermissions', reason: 'ACL query failed', error })
|
||||||
// Fail-closed: return empty set on any error
|
// Fail-closed: return empty set on any error
|
||||||
return createEmptySet()
|
return createEmptySet()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { z } from 'zod'
|
|||||||
import { auth } from '@/lib/auth'
|
import { auth } from '@/lib/auth'
|
||||||
import { canAccess, getApiAdminContext } from '@/lib/permissions'
|
import { canAccess, getApiAdminContext } from '@/lib/permissions'
|
||||||
import { type ActionResult, actionError, handleActionError } from '@/lib/safe-action-shared'
|
import { type ActionResult, actionError, handleActionError } from '@/lib/safe-action-shared'
|
||||||
|
import { logAuthorizationEvent } from '@/lib/admin/authorization-events'
|
||||||
|
|
||||||
export type { ActionResult }
|
export type { ActionResult }
|
||||||
|
|
||||||
@@ -42,6 +43,11 @@ export function adminAction<TSchema extends z.ZodType | undefined = undefined>(
|
|||||||
|
|
||||||
if (options.permission) {
|
if (options.permission) {
|
||||||
if (!canAccess(apiCtx.permissions, options.permission, apiCtx.session.user.rank)) {
|
if (!canAccess(apiCtx.permissions, options.permission, apiCtx.session.user.rank)) {
|
||||||
|
await logAuthorizationEvent({
|
||||||
|
kind: 'permission.denied', userId: apiCtx.session.user.id,
|
||||||
|
username: apiCtx.session.user.name ?? undefined, rank: apiCtx.session.user.rank,
|
||||||
|
permission: options.permission, source: 'adminAction', reason: 'Permission check denied',
|
||||||
|
})
|
||||||
return actionError('Unauthorized')
|
return actionError('Unauthorized')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { contrastRatio, readableColor } from "@/lib/theme-contrast";
|
import { contrastRatio, derivePublicForegrounds, readableColor } from "@/lib/theme-contrast";
|
||||||
import { PRESETS } from "@/lib/theme-presets";
|
import { PRESETS } from "@/lib/theme-presets";
|
||||||
|
|
||||||
describe("contrastRatio", () => {
|
describe("contrastRatio", () => {
|
||||||
@@ -64,3 +64,27 @@ describe("readableColor", () => {
|
|||||||
expect(readableColor("rgb(1, 2, 3)", ["#ffffff"], 4.5)).toBe("rgb(1, 2, 3)");
|
expect(readableColor("rgb(1, 2, 3)", ["#ffffff"], 4.5)).toBe("rgb(1, 2, 3)");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("derivePublicForegrounds", () => {
|
||||||
|
it.each(Object.entries(PRESETS))("makes every semantic pair readable for %s", (_name, preset) => {
|
||||||
|
const result = derivePublicForegrounds(preset);
|
||||||
|
for (const pair of result.pairs) {
|
||||||
|
expect(contrastRatio(pair.foreground, pair.background), pair.name).toBeGreaterThanOrEqual(4.5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repairs hostile white-on-white custom colors", () => {
|
||||||
|
const result = derivePublicForegrounds({
|
||||||
|
color_background: "#ffffff", color_surface: "#ffffff", color_dropdown: "#ffffff",
|
||||||
|
color_navbar: "#ffffff", color_navbar_text: "#ffffff", color_text: "#ffffff",
|
||||||
|
color_text_muted: "#ffffff", color_primary: "#ffffff", color_accent: "#ffffff",
|
||||||
|
button_primary_color: "#ffffff", button_text_color: "#ffffff",
|
||||||
|
button_secondary_color: "#ffffff", button_secondary_text_color: "#ffffff",
|
||||||
|
button_danger_color: "#ffffff", button_danger_text_color: "#ffffff",
|
||||||
|
button_outline_color: "#ffffff", button_outline_text_color: "#ffffff",
|
||||||
|
link_color: "#ffffff", link_hover_color: "#ffffff",
|
||||||
|
});
|
||||||
|
expect(result.pairs.length).toBeGreaterThan(10);
|
||||||
|
for (const pair of result.pairs) expect(contrastRatio(pair.foreground, pair.background), pair.name).toBeGreaterThanOrEqual(4.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,40 @@
|
|||||||
type Rgb = readonly [number, number, number];
|
type Rgb = readonly [number, number, number];
|
||||||
|
|
||||||
|
export type PublicThemeColors = Record<string, string>;
|
||||||
|
export interface ContrastPair { name: string; foreground: string; background: string }
|
||||||
|
|
||||||
|
export function derivePublicForegrounds(c: PublicThemeColors) {
|
||||||
|
const value = (key: string, fallback: string) => c[key] ?? fallback;
|
||||||
|
const background = value("color_background", "#f8fafc");
|
||||||
|
const surface = value("color_surface", "#ffffff");
|
||||||
|
const dropdown = value("color_dropdown", surface);
|
||||||
|
const navbar = value("color_navbar", surface);
|
||||||
|
const primary = value("color_primary", "#f59e0b");
|
||||||
|
const accent = value("color_accent", "#10b981");
|
||||||
|
const make = (name: string, preferred: string, bg: string): ContrastPair => ({
|
||||||
|
name, foreground: readableColor(preferred, [bg]), background: bg,
|
||||||
|
});
|
||||||
|
const pairs = [
|
||||||
|
make("body", value("color_text", "#0f172a"), background),
|
||||||
|
make("surface", value("color_text", "#0f172a"), surface),
|
||||||
|
make("muted", value("color_text_muted", "#64748b"), surface),
|
||||||
|
make("link", value("link_color", primary), surface),
|
||||||
|
make("link-hover", value("link_hover_color", primary), surface),
|
||||||
|
make("navbar", value("color_navbar_text", "#1e293b"), navbar),
|
||||||
|
make("dropdown", value("color_text", "#0f172a"), dropdown),
|
||||||
|
make("primary", value("button_text_color", "#1e293b"), primary),
|
||||||
|
make("accent", "#ffffff", accent),
|
||||||
|
make("button-primary", value("button_text_color", "#1e293b"), value("button_primary_color", primary)),
|
||||||
|
make("button-secondary", value("button_secondary_text_color", "#ffffff"), value("button_secondary_color", "#22c55e")),
|
||||||
|
make("button-danger", value("button_danger_text_color", "#ffffff"), value("button_danger_color", "#ef4444")),
|
||||||
|
make("button-outline", value("button_outline_text_color", "#1a1a2e"), surface),
|
||||||
|
make("pill", value("color_text", "#0f172a"), surface),
|
||||||
|
make("badge", value("button_text_color", "#1e293b"), primary),
|
||||||
|
make("overlay", "#ffffff", "#111827"),
|
||||||
|
];
|
||||||
|
return { pairs, byName: Object.fromEntries(pairs.map((pair) => [pair.name, pair.foreground])) };
|
||||||
|
}
|
||||||
|
|
||||||
export function contrastRatio(foreground: string, background: string): number | null {
|
export function contrastRatio(foreground: string, background: string): number | null {
|
||||||
const fg = parseHex(foreground);
|
const fg = parseHex(foreground);
|
||||||
const bg = parseHex(background);
|
const bg = parseHex(background);
|
||||||
|
|||||||
Reference in new issue
Block a user