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>
This commit is contained in:
2026-10-10 22:27:03 +00:00
co-authored by Claude Opus 5.5
parent 8c4e5049f6
commit ae10ee6c31
17 changed files with 536 additions and 127 deletions
+208
View File
@@ -0,0 +1,208 @@
// 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);
});