T-HUB/scripts/e2e/brand-palette-check.mjs
glass beadian a8c691924d e2e: S9 governed boost scenario in the governance sim
S9: boost draft (providerMetadata.sourceRemoteRef) → immediate-rule approve
→ POST /publishing/drafts/:id/amplify { connectionId, kind: repost } →
verify the source status reblogged on Mastodon. ONLY=S9 runs it standalone.
Gap list updated: boost + Temporal-queue + status-read gaps closed
(auto-publish remains a governance-model decision, not a missing route).
2026-10-13 19:40:01 +00:00

136 lines
5.1 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", `${API.replace("/api", "https://app.occupymedia.cc")}/chat`.replace("https://app.occupymedia.cc", "https://app.occupymedia.cc"), 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);