// The editor's two schemes in Chromium: in the light one Java's colours as // they were (a white surface, black labels, Java's grey program windows and // mode buttons); in the dark one, as the dashboard asks for it, everything // dark — the surface with light labels and cables, the documentation as a // tint with light words, the mode buttons freed from Java's grey ground, the // browser's own controls, and the program windows of the desktop — each pair // of text and ground readable (contrast ratio as WCAG counts it). import { expect, test, type Locator, type Page } from "@playwright/test"; import { drawing, openEditor, serveEditor, showDesktop } from "./editorPage.js"; let server: { readonly base: string; readonly close: () => Promise }; test.beforeAll(async () => { server = await serveEditor(); }); test.afterAll(async () => { await server.close(); }); /** A computed colour as red, green, blue (0–255) and alpha, whatever its notation (a mix comes as oklab): painted and read back. */ async function rgba( page: Page, color: string, ): Promise<[number, number, number, number]> { return page.evaluate((value) => { const canvas = document.createElement("canvas"); canvas.width = 1; canvas.height = 1; const context = canvas.getContext("2d")!; context.fillStyle = value; context.fillRect(0, 0, 1, 1); const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data; return [red!, green!, blue!, alpha! / 255] as [ number, number, number, number, ]; }, color); } const style = (locator: Locator, property: string) => locator.evaluate( (element, name) => getComputedStyle(element).getPropertyValue(name), property, ); /** WCAG's relative luminance of an opaque colour. */ function luminance([red, green, blue]: readonly number[]): number { const linear = (value: number) => value / 255 <= 0.03928 ? value / 255 / 12.92 : ((value / 255 + 0.055) / 1.055) ** 2.4; return ( 0.2126 * linear(red!) + 0.7152 * linear(green!) + 0.0722 * linear(blue!) ); } function contrast(one: readonly number[], other: readonly number[]): number { const [light, dark] = [luminance(one), luminance(other)].sort( (a, b) => b - a, ) as [number, number]; return (light + 0.05) / (dark + 0.05); } /** What the scheme paints, read from the page. */ async function paint(page: Page) { const surface = page.locator("svg.filius-canvas"); const rectangle = (await drawing(page)).docs.find( (item) => item.kind === "rectangle", )!; const text = (await drawing(page)).docs.find((item) => item.kind === "text")!; return { scheme: await page.evaluate( () => getComputedStyle(document.documentElement).colorScheme, ), surface: await rgba(page, await style(surface, "background-color")), label: await rgba( page, await style(page.locator("g.device text").first(), "fill"), ), cable: await rgba( page, await style(page.locator("path[data-link]").first(), "stroke"), ), rectangle: await rgba( page, await style( page.locator(`g[data-doc="${rectangle.id}"] .doc-rectangle`), "fill", ), ), words: await rgba( page, await style(page.locator(`g[data-doc="${text.id}"] .doc-text`), "color"), ), iconFilter: await style(page.locator("g.device image").first(), "filter"), pressedMode: await rgba( page, await style( page.locator('button[name="designMode"]'), "background-color", ), ), modePicture: await page .locator('button[name="designMode"] img') .evaluateAll((all) => all .filter((each) => getComputedStyle(each).display !== "none") .map((each) => each.getAttribute("data-scheme")), ), }; } /** The software installation's window on the web server's desktop: its panel, a list and its words. */ async function installation(page: Page) { await page .getByRole("button", { name: "Wechselt in den Aktionsmodus" }) .click(); await expect(page.locator("svg.filius-canvas")).toHaveAttribute( "data-mode", "simulation", ); const web = (await drawing(page)).nodes.find( (node) => node.name === "Webserver - 192.168.0.10", )!.id; await showDesktop(page, web); await page .locator(`section[data-desktop="${web}"] [data-program="installation"]`) .click(); const window = page.locator(".installation"); const list = window.locator(".installation-list").first(); return { panel: await rgba(page, await style(window, "background-color")), field: await rgba(page, await style(list, "background-color")), words: await rgba(page, await style(list.locator("li").first(), "color")), taskbar: await rgba( page, await style(page.locator(".desktop-taskbar"), "background-color"), ), }; } test("keeps Java's colours in the light scheme", async ({ page }) => { await openEditor(page, server.base, "nat_with_mapped_port.fls"); const light = await paint(page); expect(light.scheme).toBe("light"); expect(light.surface).toEqual([255, 255, 255, 1]); expect(light.label).toEqual([0, 0, 0, 1]); expect(light.cable).toEqual([64, 64, 64, 1]); // Java's grey in the design: opaque, a fifth of the way to white; its words black. expect(light.rectangle).toEqual([215, 215, 215, 1]); expect(light.words).toEqual([0, 0, 0, 1]); expect(light.modePicture).toEqual(["light"]); expect(light.iconFilter).toBe("none"); const window = await installation(page); expect(window).toEqual({ panel: [238, 238, 238, 1], field: [255, 255, 255, 1], words: [0, 0, 0, 1], taskbar: [180, 180, 180, 1], }); }); test("goes dark everywhere when the dashboard asks, every text readable on its ground", async ({ page, }) => { await openEditor( page, server.base, "nat_with_mapped_port.fls", "&theme=dark", ); const dark = await paint(page); expect(dark.scheme).toBe("dark"); expect(luminance(dark.surface)).toBeLessThan(0.05); expect(contrast(dark.label, dark.surface)).toBeGreaterThanOrEqual(7); expect(contrast(dark.cable, dark.surface)).toBeGreaterThanOrEqual(3); // A rectangle a tint of the surface, its words light on it. expect(dark.rectangle[3]).toBeLessThanOrEqual(0.3); const tinted = dark.surface .slice(0, 3) .map( (ground, index) => ground + (dark.rectangle[index]! - ground) * dark.rectangle[3], ); expect(contrast(dark.words, tinted)).toBeGreaterThanOrEqual(4.5); expect(dark.modePicture).toEqual(["dark"]); // The mode that is on shown by its button, in the accent's soft colour; each icon with a faint light outline. expect(dark.pressedMode).toEqual( await rgba( page, await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue( "--accent-soft", ), ), ), ); expect(dark.iconFilter).toContain("drop-shadow"); const window = await installation(page); expect(luminance(window.panel)).toBeLessThan(0.05); expect(luminance(window.field)).toBeLessThan(0.05); expect(luminance(window.taskbar)).toBeLessThan(0.05); expect(contrast(window.words, window.field)).toBeGreaterThanOrEqual(7); });