The surface turns dark with light labels and cables and a faint light outline round Java's icons; documentation rectangles become a tint of their colour and texts are drawn towards light; the mode buttons use Java's pictures freed from their grey ground (tools/dark-pictures/make.py); native controls follow color-scheme; the desktop's program windows paint through Java-look tokens whose light values are Java's own. The exported image and the report keep Java's colours. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
209 lines
7.1 KiB
TypeScript
209 lines
7.1 KiB
TypeScript
// 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<void> };
|
||
|
||
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);
|
||
});
|