OKLCH-based: any computed color with hue 200–300° and chroma > 0.006 on /chat or /files/drive (both iframes included) fails the check. Run after Element/oCIS image bumps; PLAYWRIGHT_PATH can point at an existing node_modules. Found by the branding panel; threshold from the brand spec.
136 lines
5 KiB
JavaScript
136 lines
5 KiB
JavaScript
#!/usr/bin/env node
|
||
// Brand palette guard: fails when any computed color on the embedded surfaces
|
||
// drifts into the blue family (OKLCH hue 200–300°, chroma > 0.006).
|
||
//
|
||
// node scripts/e2e/brand-palette-check.mjs
|
||
//
|
||
// Requires Playwright with Chromium (e.g. `pnpm dlx playwright install
|
||
// chromium`); PLAYWRIGHT_PATH can point at an existing node_modules.
|
||
//
|
||
// Surfaces checked (logged in via a fresh check account):
|
||
// /chat — the Element iframe (custom_themes must apply)
|
||
// /files/drive — the oCIS web iframe (the theme.json colorPalette must apply)
|
||
//
|
||
// Suggested wiring: run after every Element/oCIS image bump in CI.
|
||
|
||
const PLAYWRIGHT_DIR = process.env.PLAYWRIGHT_PATH ?? "";
|
||
let chromium;
|
||
try {
|
||
({ chromium } = await import(`${PLAYWRIGHT_DIR}/playwright/index.mjs`));
|
||
} catch {
|
||
({ chromium } = await import("playwright"));
|
||
}
|
||
|
||
const API = (process.env.API_URL ?? "https://api.occupymedia.cc").replace(/\/$/, "");
|
||
const USER = `palette-check-${Date.now()}`;
|
||
const PASS = `PaletteCheck!${Math.floor(Math.random() * 1e6)}`;
|
||
|
||
/** sRGB (0-255) → OKLCH {L, C, H}. */
|
||
function srgbToOklch(r, g, b) {
|
||
const lin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
|
||
const R = lin(r), G = lin(g), B = lin(b);
|
||
const l = 0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B;
|
||
const m = 0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B;
|
||
const s = 0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B;
|
||
const l_ = Math.cbrt(l), m_ = Math.cbrt(m), s_ = Math.cbrt(s);
|
||
const L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
|
||
const A = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
|
||
const Bc = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
|
||
const C = Math.sqrt(A * A + Bc * Bc);
|
||
let H = (Math.atan2(Bc, A) * 180) / Math.PI;
|
||
if (H < 0) H += 360;
|
||
return { L, C, H };
|
||
}
|
||
|
||
function parseColor(value) {
|
||
const hex = value.match(/^#([0-9a-f]{6})$/i);
|
||
if (hex) {
|
||
const v = hex[1];
|
||
return srgbToOklch(parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16));
|
||
}
|
||
const rgb = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
|
||
if (rgb) return srgbToOklch(Number(rgb[1]), Number(rgb[2]), Number(rgb[3]));
|
||
return null;
|
||
}
|
||
|
||
/** Blue-family: OKLCH hue 200–300° with visible chroma. */
|
||
function isBlue({ C, H }) {
|
||
return C > 0.006 && H >= 200 && H <= 300;
|
||
}
|
||
|
||
async function collectFrameColors(frame) {
|
||
return frame.evaluate(() => {
|
||
const seen = new Map();
|
||
for (const node of document.querySelectorAll("*")) {
|
||
const cs = getComputedStyle(node);
|
||
for (const prop of ["backgroundColor", "color", "borderTopColor", "borderBottomColor"]) {
|
||
const value = cs[prop];
|
||
if (value && value !== "rgba(0, 0, 0, 0)" && value !== "transparent") {
|
||
seen.set(value, (seen.get(value) ?? 0) + 1);
|
||
}
|
||
}
|
||
}
|
||
return [...seen.entries()].map(([value, count]) => ({ value, count })).sort((a, b) => b.count - a.count);
|
||
});
|
||
}
|
||
|
||
const api = {
|
||
async call(method, path, body, token) {
|
||
const res = await fetch(`${API}${path}`, {
|
||
method,
|
||
headers: {
|
||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||
...(body ? { "content-type": "application/json" } : {}),
|
||
},
|
||
...(body ? { body: JSON.stringify(body) } : {}),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
return { status: res.status, data };
|
||
},
|
||
};
|
||
|
||
const browser = await chromium.launch();
|
||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||
const page = await ctx.newPage();
|
||
|
||
const reg = await api.call("POST", "/auth/register", {
|
||
username: USER, password: PASS, displayName: "Palette Guard",
|
||
});
|
||
if (reg.status >= 300 && reg.status !== 409) throw new Error(`register failed: ${reg.status}`);
|
||
const token = reg.data.token ?? (await api.call("POST", "/auth/login", { username: USER, password: PASS })).data.token;
|
||
|
||
const failures = [];
|
||
|
||
async function checkSurface(name, url, waitMs) {
|
||
await page.goto(url);
|
||
await page.waitForTimeout(waitMs);
|
||
for (const frame of page.frames()) {
|
||
let colors;
|
||
try {
|
||
colors = await collectFrameColors(frame);
|
||
} catch {
|
||
continue; // detached or inaccessible frame
|
||
}
|
||
for (const { value, count } of colors) {
|
||
const oklch = parseColor(value);
|
||
if (oklch && isBlue(oklch)) {
|
||
failures.push({ surface: name, frame: frame.url().slice(0, 60), value, count, oklch });
|
||
}
|
||
}
|
||
}
|
||
console.log(`checked ${name}`);
|
||
}
|
||
|
||
await checkSurface("/chat", "https://app.occupymedia.cc/chat", 16000);
|
||
await checkSurface("/files/drive", "https://app.occupymedia.cc/files/drive", 15000);
|
||
|
||
if (failures.length > 0) {
|
||
console.error("BLUE VIOLATIONS:");
|
||
for (const f of failures) {
|
||
console.error(` ${f.surface} | ${f.frame} | ${f.value} ×${f.count} | OKLCH ${f.oklch.L.toFixed(3)} ${f.oklch.C.toFixed(3)} ${f.oklch.H.toFixed(1)}°`);
|
||
}
|
||
process.exit(1);
|
||
}
|
||
console.log("PASS — no blue-family colors on any embedded surface");
|
||
await browser.close();
|
||
process.exit(0);
|