T-HUB/scripts/e2e/brand-palette-check.mjs
glass beadian ea1a9bb4e5 e2e: brand palette guard — fails on blue-family colors in the embedded surfaces
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.
2026-10-13 19:42:06 +00:00

136 lines
5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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);