Files
mdg-filius/packages/editor/browser/theme.spec.ts
T
julian.kiedaischandClaude Opus 5.5 ae10ee6c31 feat(editor): the dark scheme everywhere — the surface, documentation, mode buttons, program windows
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>
2026-10-10 22:27:03 +00:00

209 lines
7.1 KiB
TypeScript
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.
// 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);
});