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