Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions app/api/routes-f/contrast/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { NextRequest } from "next/server";
import { POST } from "../route";
function makeReq(body: unknown) {
return new NextRequest("http://localhost/api/routes-f/contrast", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
describe("POST /api/routes-f/contrast", () => {
it("matches WCAG reference ratio for black/white", async () => {
const res = await POST(
makeReq({ foreground: "#000000", background: "#ffffff" })
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ratio).toBe(21);
expect(body.levels).toEqual({
aa_normal: true,
aa_large: true,
aaa_normal: true,
aaa_large: true,
});
});
it("supports rgb() input", async () => {
const res = await POST(
makeReq({ foreground: "rgb(255, 255, 255)", background: "rgb(0, 0, 0)" })
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ratio).toBe(21);
});
it("evaluates all WCAG levels for known failing pair", async () => {
const res = await POST(
makeReq({ foreground: "#777777", background: "#ffffff" })
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.levels.aa_normal).toBe(false);
expect(body.levels.aa_large).toBe(true);
expect(body.levels.aaa_normal).toBe(false);
expect(body.levels.aaa_large).toBe(false);
});
it("rejects invalid color strings", async () => {
const res = await POST(
makeReq({ foreground: "nope", background: "#ffffff" })
);
expect(res.status).toBe(400);
});
});
81 changes: 81 additions & 0 deletions app/api/routes-f/contrast/_lib/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
type Rgb = { r: number; g: number; b: number };

const HEX_PATTERN = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
const RGB_PATTERN = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i;

function isRgbChannel(value: number): boolean {
return Number.isInteger(value) && value >= 0 && value <= 255;
}

function expandShortHex(hex: string): string {
return hex
.split("")
.map(char => `${char}${char}`)
.join("");
}

export function parseColor(input: string): Rgb | null {
const trimmed = input.trim();

const hexMatch = trimmed.match(HEX_PATTERN);
if (hexMatch) {
const rawHex = hexMatch[1].toLowerCase();
const fullHex = rawHex.length === 3 ? expandShortHex(rawHex) : rawHex;

return {
r: parseInt(fullHex.slice(0, 2), 16),
g: parseInt(fullHex.slice(2, 4), 16),
b: parseInt(fullHex.slice(4, 6), 16),
};
}

const rgbMatch = trimmed.match(RGB_PATTERN);
if (rgbMatch) {
const r = Number(rgbMatch[1]);
const g = Number(rgbMatch[2]);
const b = Number(rgbMatch[3]);

if (!isRgbChannel(r) || !isRgbChannel(g) || !isRgbChannel(b)) {
return null;
}

return { r, g, b };
}

return null;
}

function toLinear(channel: number): number {
const normalized = channel / 255;
return normalized <= 0.03928
? normalized / 12.92
: Math.pow((normalized + 0.055) / 1.055, 2.4);
}
export function relativeLuminance(rgb: Rgb): number {
return (
0.2126 * toLinear(rgb.r) +
0.7152 * toLinear(rgb.g) +
0.0722 * toLinear(rgb.b)
);
}

export function contrastRatio(foreground: Rgb, background: Rgb): number {
const fgLum = relativeLuminance(foreground);
const bgLum = relativeLuminance(background);
const lighter = Math.max(fgLum, bgLum);
const darker = Math.min(fgLum, bgLum);
return (lighter + 0.05) / (darker + 0.05);
}

export function roundToTwo(value: number): number {
return Math.round((value + Number.EPSILON) * 100) / 100;
}

export function wcagLevels(ratio: number) {
return {
aa_normal: ratio >= 4.5,
aa_large: ratio >= 3,
aaa_normal: ratio >= 7,
aaa_large: ratio >= 4.5,
};
}
14 changes: 14 additions & 0 deletions app/api/routes-f/contrast/_lib/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export type ContrastRequest = {
foreground: string;
background: string;
};
export type ContrastLevels = {
aa_normal: boolean;
aa_large: boolean;
aaa_normal: boolean;
aaa_large: boolean;
};
export type ContrastResponse = {
ratio: number;
levels: ContrastLevels;
};
42 changes: 42 additions & 0 deletions app/api/routes-f/contrast/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import {
contrastRatio,
parseColor,
roundToTwo,
wcagLevels,
} from "./_lib/helpers";
import type { ContrastRequest, ContrastResponse } from "./_lib/types";
export async function POST(req: NextRequest) {
let body: ContrastRequest;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
}
if (
typeof body?.foreground !== "string" ||
typeof body?.background !== "string"
) {
return NextResponse.json(
{
error:
"foreground and background must be color strings in hex or rgb() format.",
},
{ status: 400 }
);
}
const foreground = parseColor(body.foreground);
const background = parseColor(body.background);
if (!foreground || !background) {
return NextResponse.json(
{ error: "Invalid color format. Use hex or rgb()." },
{ status: 400 }
);
}
const rawRatio = contrastRatio(foreground, background);
const response: ContrastResponse = {
ratio: roundToTwo(rawRatio),
levels: wcagLevels(rawRatio),
};
return NextResponse.json(response);
}
69 changes: 69 additions & 0 deletions app/api/routes-f/date-diff/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { NextRequest } from "next/server";
import { POST } from "../route";
function makeReq(body: unknown) {
return new NextRequest("http://localhost/api/routes-f/date-diff", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
describe("POST /api/routes-f/date-diff", () => {
it("handles leap-year calendar math", async () => {
const res = await POST(
makeReq({
from: "2024-02-29T00:00:00Z",
to: "2025-03-01T00:00:00Z",
})
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.breakdown.years).toBe(1);
expect(body.breakdown.months).toBe(0);
expect(body.breakdown.days).toBe(1);
expect(body.human).toContain("in");
});
it("captures DST spring-forward absolute delta", async () => {
const res = await POST(
makeReq({
from: "2026-03-08T01:30:00-05:00",
to: "2026-03-08T03:30:00-04:00",
})
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.total_seconds).toBe(3600);
});
it("captures DST fall-back absolute delta", async () => {
const res = await POST(
makeReq({
from: "2026-11-01T01:30:00-04:00",
to: "2026-11-01T01:30:00-05:00",
})
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.total_seconds).toBe(3600);
});
it("returns negative values when to is before from", async () => {
const res = await POST(
makeReq({
from: "2026-01-01T12:00:00Z",
to: "2026-01-01T09:00:00Z",
})
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.total_seconds).toBe(-10800);
expect(body.human.endsWith("ago")).toBe(true);
});
it("rejects invalid unit", async () => {
const res = await POST(
makeReq({
from: "2026-01-01T12:00:00Z",
to: "2026-01-01T13:00:00Z",
unit: "seconds",
})
);
expect(res.status).toBe(400);
});
});
Loading
Loading