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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user