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
+1
View File
@@ -278,6 +278,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `src/sheet/sheet.css` (`.editor-root`, Handy), `src/Editor.tsx` (Wurzel), `browser/sheet.spec.ts` | — | geändert (10.10.2026, auf Wunsch des Nutzers): auf dem Handy (bis 639 px) nimmt das Geräte-Blatt den ganzen Editor ein, über der Leiste und den Modusknöpfen; eine Meldung bleibt darüber, Schließen gibt die Fläche zurück. Vorher stand es unter der Fläche (höchstens halb so hoch), neben einer Leiste von 137 px blieben ihm 253 von 390 px. Folge: die DHCP-Segmente stehen auf einem 390-px-Handy jetzt nebeneinander, untereinander erst unter etwa 335 px |
| `src/simulation/PropertiesSheet.tsx` (neu), `src/simulation/DropHold.tsx` (vorher `DropControl.tsx`), `src/simulation/useSimulation.ts` (`restart`), `src/Editor.tsx`, `src/sheet/sheet.css`, `src/desktop/desktop.css`, `src/i18n/*.json` (`properties.*`; `drop.list` entfällt), `browser/viewers.spec.ts`, `browser/shared.spec.ts`, `browser/fromF.spec.ts`, `tools/live/acceptance.ts` | Java `SimulationControl` | geändert (10.10.2026, auf Wunsch des Nutzers): im Aktionsmodus ein eigener Inspector „Eigenschaften“ (Knopf über der Fläche, Blatt neben ihr wie die Geräte-Einstellungen, auf dem Handy über dem ganzen Editor) statt Hinweis und Knöpfen in der Werkzeugleiste: wer die Simulation gestartet hat, „Simulation neu starten“ (für alle, nach einer Rückfrage; die Tabellen des Datenaustauschs verlieren ihre Auswahl), „Pakete verwerfen“ und darunter die verworfenen Pakete samt Anzahl. Wer nur zusieht, sieht Starter und Liste, ohne Neustart und Verwerfen |
| `src/canvas/handles.ts` (neu), `src/canvas/Canvas.tsx` (Griffe, Durchtippen, Platz von „⋯“), `src/sheet/sheet.css` (`.doc-handle`), `src/i18n/*.json` (`docu.resize`, `docu.move`), `test/handles.test.ts`, `browser/touch.spec.ts`, `browser/documentation.spec.ts` | Java `JDocuElement` (Rand von 7 zum Greifen) | erweitert (10.10.2026, auf Wunsch des Nutzers): im Dokumentationsmodus zeigt das fokussierte Element Griffe für den Finger — Trefferfläche 44 px, Punkt in Javas Fokusblau auf dem Fokusband: an den Ecken, ab drei Zielen Länge auch an den Kantenmitten; auf einem Element unter zwei Zielen nur rechts unten, außen an der Ecke, damit seine Mitte verschiebt und das Menü öffnet. Ein Text hat über seiner linken oberen Ecke einen Verschiebe-Griff (seine Mitte ist das Eingabefeld). „⋯“ steht über der rechten oberen Ecke; Griff und „⋯“ weichen am Rand der Fläche aus. Ein Tippen auf einen Griff gilt dem, was darunter liegt; langes Drücken öffnet das Menü. Gemessen: Javas Rand war 10–14 Bildschirm-Pixel breit und unsichtbar. WebKit gibt das Ende eines Tippens dem Griff statt der Fläche, die die Berührung gefangen hat; der Griff reicht seine Ereignisse deshalb weiter. Javas Rand für die Maus bleibt. Abweichend von Java: dort keine Griffe |
| `src/theme/tokens.css` (Fläche, `--doc-text-border`, `--java-*`, `color-scheme`), `src/desktop/desktop.css` (109 feste Farben → `--java-*`), `src/canvas/documentationPaint.ts` (`darkRectangleFill`, `darkRectangleBorder`, `darkTextColor`), `src/canvas/Canvas.tsx`, `src/design/DocumentationText.tsx`, `src/simulation/ModeBar.tsx`, `src/sheet/sheet.css`, `src/assets/modes/*_dunkel.png` (neu, aus Javas Bildern freigestellt: `tools/dark-pictures/make.py`), `test/documentationPaint.test.ts`, `browser/theme.spec.ts` (neu) | Java `JCablePanel`, `JDocuElement`, `GUIMainMenu` (Modusbilder), Swing-Metal in den Programmfenstern | geändert (10.10.2026, auf Wunsch des Nutzers): das dunkle Schema überall — die Fläche dunkel (#262b33, vorher bewusst hell #e9ecf1) mit heller Beschriftung, helleren Kabeln und einem feinen hellen Rand um Javas Symbole (sonst versinken die schwarzen Switches); Gliederungsflächen als Tönung ihrer Farbe, Texte zur hellen Schrift hin gemischt; die Modusknöpfe mit freigestellten Bildern, der gedrückte Knopf in der Akzentfarbe; `color-scheme: dark` für Kästchen, Regler und Auswahllisten; die Programmfenster des Desktops über Java-Look-Token, hell mit Javas Werten unverändert. Hell bleiben Javas Desktop-Hintergrund und die Webseite im simulierten Browser (Inhalt). Exportiertes Bild und Bericht malen weiter Javas Farben |
## packages/server
+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);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 950 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+6 -3
View File
@@ -36,7 +36,7 @@ import { bounds, cablePath, deviceBox, ICONS, iconOf, JAVA_LABEL_FONT, type Devi
import { fitted, middle, shifted, spread, toScreen, toSurface, zoomedAt, zoomIn, zoomOut, type Point, type Size, type Viewport } from './viewport.js';
import { ZoomBar } from './ZoomBar.js';
import { gripPlace, handlePlace, HANDLES, menuPlace, NO_EDGE, shownHandles, TARGET, type Edges } from './handles.js';
import { EDGE_BAND, edgesAt, FOCUS_BAND, FOCUS_BORDER, fontFamily, holdFrame, rectangleBorder, rectangleFill, TEXT_BORDER, textColor, type Frame } from './documentationPaint.js';
import { darkRectangleBorder, darkRectangleFill, darkTextColor, EDGE_BAND, edgesAt, FOCUS_BAND, FOCUS_BORDER, fontFamily, holdFrame, rectangleBorder, rectangleFill, textColor, type Frame } from './documentationPaint.js';
/** How far a pointer moves before a press becomes a drag. */
export const DRAG_THRESHOLD = 4;
@@ -152,6 +152,8 @@ function Documentation({ item, documenting, focused }: { readonly item: Document
fill={rectangleFill(item.color, documenting)}
stroke={focused ? 'none' : rectangleBorder(item.color)}
strokeWidth={1}
// The dark scheme's paint (sheet.css takes it): Java's is for a white surface.
style={{ '--doc-fill-dark': darkRectangleFill(item.color, documenting), '--doc-stroke-dark': focused ? 'none' : darkRectangleBorder(item.color) } as CSSProperties}
/>
</g>
);
@@ -166,14 +168,15 @@ function Documentation({ item, documenting, focused }: { readonly item: Document
style={{
boxSizing: 'border-box',
padding: 1,
color: textColor(item.color),
// The words' colour, in the dark scheme drawn towards light (sheet.css).
...({ '--doc-ink': textColor(item.color), '--doc-ink-dark': darkTextColor(item.color) } as CSSProperties),
font: `${item.italic ? 'italic ' : ''}${item.bold ? 'bold ' : ''}${item.size}px ${fontFamily(item.font)}`,
whiteSpace: 'pre-wrap',
overflowWrap: 'break-word',
overflow: 'hidden',
width: '100%',
height: '100%',
border: documenting && !focused ? `1px solid ${TEXT_BORDER}` : undefined,
border: documenting && !focused ? '1px solid var(--doc-text-border)' : undefined,
visibility: focused ? 'hidden' : undefined,
}}
>
@@ -40,6 +40,24 @@ export function textColor(color: Color): string {
return `rgba(${byte(color.red)}, ${byte(color.green)}, ${byte(color.blue)}, ${color.alpha})`;
}
/**
* The editor's dark scheme (not Java's — it has none; the exported image and
* the report keep Java's paint): a rectangle's colour as a tint of the dark
* surface, stronger while documenting, its border the colour itself, faded.
*/
export function darkRectangleFill(color: Color, documenting: boolean): string {
return `rgba(${byte(color.red)}, ${byte(color.green)}, ${byte(color.blue)}, ${documenting ? 0.28 : 0.2})`;
}
export function darkRectangleBorder(color: Color): string {
return `rgba(${byte(color.red)}, ${byte(color.green)}, ${byte(color.blue)}, 0.7)`;
}
/** A text's colour on the dark surface: drawn towards the light text colour, so that Java's black reads as light and a red as a light red. */
export function darkTextColor(color: Color): string {
return `color-mix(in oklab, ${textColor(color)} 15%, var(--canvas-label))`;
}
/** Java's logical font names as the browser has them; any other a font of that name, else sans-serif. */
export function fontFamily(name: string): string {
switch (name.toLowerCase()) {
@@ -8,7 +8,7 @@ import { useEffect, useRef } from 'react';
import type * as Y from 'yjs';
import { replaceText, type DocumentationView } from '@mdg-filius/doc';
import type { ScreenFrame } from '../canvas/Canvas.js';
import { fontFamily, textColor } from '../canvas/documentationPaint.js';
import { darkTextColor, fontFamily, textColor } from '../canvas/documentationPaint.js';
import { followText } from '../desktop/textBinding.js';
import { useTranslate } from '../i18n/i18n.js';
@@ -37,6 +37,7 @@ export function DocumentationText({ item, frame, text, origin, onDone, onRemove
ref={area}
aria-label={t('docu.text')}
data-doc-text={item.id}
className="doc-text-field"
spellCheck={false}
onInput={(event) => {
const next = event.currentTarget.value;
@@ -67,7 +68,8 @@ export function DocumentationText({ item, frame, text, origin, onDone, onRemove
resize: 'none',
overflow: 'hidden',
background: 'transparent',
color: textColor(item.color),
// The words' colour as the drawn text has it, in the dark scheme too (sheet.css).
...({ '--doc-ink': textColor(item.color), '--doc-ink-dark': darkTextColor(item.color) } as React.CSSProperties),
font: `${item.italic ? 'italic ' : ''}${item.bold ? 'bold ' : ''}${item.size}px ${fontFamily(item.font)}`,
lineHeight: 'normal',
whiteSpace: 'pre-wrap',
+109 -109
View File
@@ -167,9 +167,9 @@
.desktop-network {
margin: 0;
padding: 8px 12px;
border: 2px inset #c0c0c0;
background: #eeeeee;
color: #000000;
border: 2px inset var(--java-grid);
background: var(--java-panel);
color: var(--java-text);
font: 13px Dialog, sans-serif;
}
@@ -187,8 +187,8 @@
.desktop-network dd {
padding: 2px 6px;
background: #ffffff;
border: 1px solid #b0b0b0;
background: var(--java-field);
border: 1px solid var(--java-line);
font-variant-numeric: tabular-nums;
user-select: text;
}
@@ -199,14 +199,14 @@
align-items: stretch;
height: 44px;
flex: none;
background: rgb(180 180 180);
background: var(--java-taskbar);
}
.desktop-home,
.desktop-network-icon {
border: none;
background: transparent;
color: #000000;
color: var(--java-text);
font: 13px Dialog, sans-serif;
cursor: pointer;
}
@@ -228,7 +228,7 @@
inset: 0;
display: flex;
flex-direction: column;
background: #ffffff;
background: var(--java-field);
}
.program-titlebar {
@@ -343,8 +343,8 @@
align-items: center;
gap: 10px;
padding: 10px;
background: #eeeeee;
color: #000000;
background: var(--java-panel);
color: var(--java-text);
font: 13px Dialog, sans-serif;
}
@@ -371,8 +371,8 @@
margin: 0;
padding: 0;
list-style: none;
background: #ffffff;
border: 1px solid #a0a0a0;
background: var(--java-field);
border: 1px solid var(--java-border);
}
.installation-list li {
@@ -385,11 +385,11 @@
}
.installation-list li[aria-selected='true'] {
background: rgb(184 207 229);
background: var(--java-selected);
}
.installation-list li:focus-visible {
outline: 2px solid rgb(99 130 191);
outline: 2px solid var(--java-focus);
outline-offset: -2px;
}
@@ -404,10 +404,10 @@
.installation-apply {
min-width: 44px;
min-height: 44px;
border: 1px solid #a0a0a0;
border: 1px solid var(--java-border);
border-radius: 6px;
background: #f8f8f8;
color: #000000;
background: var(--java-button);
color: var(--java-text);
font: 13px Dialog, sans-serif;
cursor: pointer;
}
@@ -425,7 +425,7 @@
min-height: 0;
display: flex;
flex-direction: column;
background: #eeeeee;
background: var(--java-panel);
font: 13px Dialog, sans-serif;
}
@@ -441,9 +441,9 @@
min-width: 0;
min-height: 36px;
padding: 0 6px;
border: 1px solid #a0a0a0;
background: #ffffff;
color: #000000;
border: 1px solid var(--java-border);
background: var(--java-field);
color: var(--java-text);
font: 16px Dialog, sans-serif;
}
@@ -452,10 +452,10 @@
min-width: 44px;
min-height: 36px;
padding: 0 14px;
border: 1px solid #a0a0a0;
border: 1px solid var(--java-border);
border-radius: 6px;
background: #f8f8f8;
color: #000000;
background: var(--java-button);
color: var(--java-text);
font: 13px Dialog, sans-serif;
cursor: pointer;
}
@@ -466,7 +466,7 @@
margin: 0 5px 5px;
display: flex;
background: #ffffff;
border: 1px solid #a0a0a0;
border: 1px solid var(--java-border);
}
.browser-page iframe {
@@ -496,8 +496,8 @@
flex-direction: column;
gap: 10px;
padding: 5px 10px 10px;
background: #eeeeee;
color: #000000;
background: var(--java-panel);
color: var(--java-text);
font: 13px Dialog, sans-serif;
}
@@ -533,20 +533,20 @@
width: 100%;
border-collapse: collapse;
table-layout: fixed;
background: #ffffff;
background: var(--java-field);
}
.web-server-hosts th {
padding: 4px;
border: 1px solid #a0a0a0;
background: #e4e4e4;
border: 1px solid var(--java-border);
background: var(--java-head);
font-weight: normal;
text-align: left;
}
.web-server-hosts td {
padding: 0;
border: 1px solid #c0c0c0;
border: 1px solid var(--java-grid);
}
.web-server-hosts td input {
@@ -556,7 +556,7 @@
padding: 0 5px;
border: none;
background: transparent;
color: #000000;
color: var(--java-text);
font: 16px Dialog, sans-serif;
}
@@ -566,8 +566,8 @@
margin: 0;
overflow: auto;
padding: 4px;
border: 1px solid #a0a0a0;
background: #ffffff;
border: 1px solid var(--java-border);
background: var(--java-field);
font: 12px ui-monospace, Menlo, Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
@@ -588,8 +588,8 @@
flex-direction: column;
gap: 5px;
padding: 5px;
background: #eeeeee;
color: #000000;
background: var(--java-panel);
color: var(--java-text);
font: 13px Dialog, sans-serif;
}
@@ -612,10 +612,10 @@
min-width: 44px;
min-height: 40px;
padding: 0 12px;
border: 1px solid #a0a0a0;
border: 1px solid var(--java-border);
border-radius: 6px;
background: #f8f8f8;
color: #000000;
background: var(--java-button);
color: var(--java-text);
font: 13px Dialog, sans-serif;
cursor: pointer;
}
@@ -642,23 +642,23 @@
min-width: 0;
min-height: 32px;
padding: 0 6px;
border: 1px solid #a0a0a0;
background: #ffffff;
color: #000000;
border: 1px solid var(--java-border);
background: var(--java-field);
color: var(--java-text);
font: 16px Dialog, sans-serif;
}
.mail-client [aria-invalid='true'],
.mail-server [aria-invalid='true'] {
border-color: #ff0000;
color: #ff0000;
border-color: var(--java-invalid);
color: var(--java-invalid);
}
.mail-tabs {
display: flex;
gap: 2px;
flex: none;
border-bottom: 1px solid #a0a0a0;
border-bottom: 1px solid var(--java-border);
}
.mail-tabs button {
@@ -667,17 +667,17 @@
gap: 6px;
min-height: 40px;
padding: 0 12px;
border: 1px solid #a0a0a0;
border: 1px solid var(--java-border);
border-bottom: none;
border-radius: 6px 6px 0 0;
background: #e0e0e0;
color: #000000;
background: var(--java-tab);
color: var(--java-text);
font: 13px Dialog, sans-serif;
cursor: pointer;
}
.mail-tabs button[aria-selected='true'] {
background: #ffffff;
background: var(--java-field);
}
.mail-list,
@@ -685,8 +685,8 @@
flex: 1;
min-height: 80px;
overflow: auto;
background: #ffffff;
border: 1px solid #a0a0a0;
background: var(--java-field);
border: 1px solid var(--java-border);
}
.mail-accounts {
@@ -711,8 +711,8 @@
.mail-list th,
.mail-accounts th {
padding: 4px 6px;
border-bottom: 1px solid #a0a0a0;
background: #e4e4e4;
border-bottom: 1px solid var(--java-border);
background: var(--java-head);
font-weight: normal;
text-align: left;
}
@@ -729,7 +729,7 @@
.mail-list tr[aria-selected='true'] td,
.mail-accounts tr[aria-selected='true'] td {
background: rgb(184 207 229);
background: var(--java-selected);
}
.mail-preview {
@@ -737,8 +737,8 @@
min-height: 80px;
overflow: auto;
padding: 4px;
border: 1px solid #a0a0a0;
background: #ffffff;
border: 1px solid var(--java-border);
background: var(--java-field);
}
.mail-preview pre,
@@ -763,8 +763,8 @@
min-height: 80px;
overflow: auto;
padding: 4px;
border: 1px solid #a0a0a0;
background: #ffffff;
border: 1px solid var(--java-border);
background: var(--java-field);
}
.mail-form {
@@ -805,7 +805,7 @@
gap: 6px;
overflow: auto;
padding: 0 8px 8px;
background: #eeeeee;
background: var(--java-panel);
}
.mail-dialog h4 {
@@ -821,7 +821,7 @@
flex: 1;
min-height: 120px;
padding: 4px;
border: 1px solid #a0a0a0;
border: 1px solid var(--java-border);
font: 16px ui-monospace, Menlo, Consolas, monospace;
}
@@ -833,9 +833,9 @@
transform: translateX(-50%);
width: min(90%, 380px);
padding: 12px;
border: 1px solid #808080;
border: 1px solid var(--java-border-strong);
border-radius: 6px;
background: #ffffff;
background: var(--java-field);
box-shadow: 0 4px 16px rgb(0 0 0 / 30%);
}
@@ -858,7 +858,7 @@
flex-direction: column;
gap: 5px;
padding: 5px;
background: #eeeeee;
background: var(--java-panel);
}
.text-editor-bar {
@@ -874,17 +874,17 @@
min-width: 44px;
min-height: 40px;
padding: 0 12px;
border: 1px solid #a0a0a0;
border: 1px solid var(--java-border);
border-radius: 6px;
background: #f8f8f8;
color: #000000;
background: var(--java-button);
color: var(--java-text);
font: 13px Dialog, sans-serif;
cursor: pointer;
}
.text-editor-bar button:disabled,
.file-dialog-folder button:disabled {
color: #a0a0a0;
color: var(--java-disabled);
cursor: default;
}
@@ -893,9 +893,9 @@
min-height: 0;
resize: none;
padding: 4px;
border: 1px solid #a0a0a0;
background: #ffffff;
color: #000000;
border: 1px solid var(--java-border);
background: var(--java-field);
color: var(--java-text);
font: 16px 'Courier New', Courier, monospace;
}
@@ -919,8 +919,8 @@
margin: 0;
padding: 0;
overflow: auto;
border: 1px solid #a0a0a0;
background: #ffffff;
border: 1px solid var(--java-border);
background: var(--java-field);
list-style: none;
}
@@ -933,14 +933,14 @@
padding: 0 6px;
border: 0;
background: transparent;
color: #000000;
color: var(--java-text);
font: 13px Dialog, sans-serif;
text-align: left;
cursor: pointer;
}
.file-dialog-entries button[aria-pressed='true'] {
background: #b8cfe5;
background: var(--java-selected);
}
/* The file explorer (GUIApplicationFileExplorerWindow): the menu's actions
@@ -954,7 +954,7 @@
flex-direction: column;
gap: 5px;
padding: 5px;
background: #eeeeee;
background: var(--java-panel);
}
.file-explorer-bar {
@@ -968,16 +968,16 @@
min-width: 44px;
min-height: 40px;
padding: 0 10px;
border: 1px solid #a0a0a0;
border: 1px solid var(--java-border);
border-radius: 6px;
background: #f8f8f8;
color: #000000;
background: var(--java-button);
color: var(--java-text);
font: 13px Dialog, sans-serif;
cursor: pointer;
}
.file-explorer-bar button:disabled {
color: #a0a0a0;
color: var(--java-disabled);
cursor: default;
}
@@ -995,8 +995,8 @@
margin: 0;
padding: 0;
overflow: auto;
border: 1px solid #a0a0a0;
background: #ffffff;
border: 1px solid var(--java-border);
background: var(--java-field);
list-style: none;
}
@@ -1010,7 +1010,7 @@
padding: 0 6px;
border: 0;
background: transparent;
color: #000000;
color: var(--java-text);
font: 13px Dialog, sans-serif;
text-align: left;
cursor: pointer;
@@ -1019,7 +1019,7 @@
.file-explorer-tree button[aria-current='true'],
.file-explorer-tree button[aria-pressed='true'],
.file-explorer-entries button[aria-pressed='true'] {
background: #b8cfe5;
background: var(--java-selected);
}
@media (max-width: 639px) {
@@ -1048,7 +1048,7 @@
flex-direction: column;
gap: 5px;
padding: 5px;
background: #eeeeee;
background: var(--java-panel);
}
.image-viewer-picture {
@@ -1071,9 +1071,9 @@
.simple-client textarea {
min-height: 32px;
padding: 0 6px;
border: 1px solid #a0a0a0;
background: #ffffff;
color: #000000;
border: 1px solid var(--java-border);
background: var(--java-field);
color: var(--java-text);
font: 16px Dialog, sans-serif;
}
@@ -1083,13 +1083,13 @@
.echo-server [aria-invalid='true'],
.simple-client [aria-invalid='true'] {
border-color: #ff0000;
color: #ff0000;
border-color: var(--java-invalid);
color: var(--java-invalid);
}
.echo-server input:read-only,
.simple-client input:read-only {
background: #eeeeee;
background: var(--java-panel);
}
.simple-client-field,
@@ -1134,15 +1134,15 @@
.dns-server input:not([type='checkbox']) {
min-height: 32px;
padding: 0 6px;
border: 1px solid #a0a0a0;
background: #ffffff;
color: #000000;
border: 1px solid var(--java-border);
background: var(--java-field);
color: var(--java-text);
font: 16px Dialog, sans-serif;
}
.dns-server [aria-invalid='true'] {
border-color: #ff0000;
color: #ff0000;
border-color: var(--java-invalid);
color: var(--java-invalid);
}
.dns-server-notice {
@@ -1152,19 +1152,19 @@
.dns-server-table {
width: 100%;
border-collapse: collapse;
background: #ffffff;
background: var(--java-field);
}
.dns-server-table th {
padding: 4px 6px;
border-bottom: 1px solid #a0a0a0;
border-bottom: 1px solid var(--java-border);
text-align: left;
font-weight: normal;
}
.dns-server-table td {
padding: 2px;
border-bottom: 1px solid #dddddd;
border-bottom: 1px solid var(--java-rule);
}
.dns-server-table td input {
@@ -1175,7 +1175,7 @@
.dns-server-table tr[aria-selected='true'] td,
.dns-server-table tr[aria-selected='true'] input {
background: #b8cfe5;
background: var(--java-selected);
}
/* A computer's firewall (GUIApplicationFirewallWindow): two tabs, the boxes,
@@ -1215,9 +1215,9 @@
.personal-firewall select {
min-height: 32px;
padding: 0 6px;
border: 1px solid #a0a0a0;
background: #ffffff;
color: #000000;
border: 1px solid var(--java-border);
background: var(--java-field);
color: var(--java-text);
font: 16px Dialog, sans-serif;
}
@@ -1226,8 +1226,8 @@
}
.personal-firewall [aria-invalid='true'] {
border-color: #ff0000;
color: #ff0000;
border-color: var(--java-invalid);
color: var(--java-invalid);
}
.personal-firewall-table td {
@@ -1269,15 +1269,15 @@
.gnutella input {
min-height: 32px;
padding: 0 6px;
border: 1px solid #a0a0a0;
background: #ffffff;
color: #000000;
border: 1px solid var(--java-border);
background: var(--java-field);
color: var(--java-text);
font: 16px Dialog, sans-serif;
}
.gnutella [aria-invalid='true'] {
border-color: #ff0000;
color: #ff1414;
border-color: var(--java-invalid);
color: var(--java-invalid-text);
}
.gnutella-table td {
+44
View File
@@ -50,6 +50,30 @@
overflow-y: auto;
}
/* Documentation on the surface: a text's words in their colour; in the dark
* scheme a rectangle as a tint and the words drawn towards light
* (documentationPaint.ts) — Java paints for a white surface. Java's icons get
* a faint light outline there, or its black switches and routers would sink
* into the dark. */
.doc-text,
.doc-text-field {
color: var(--doc-ink);
}
:root[data-theme='dark'] .doc-text,
:root[data-theme='dark'] .doc-text-field {
color: var(--doc-ink-dark);
}
:root[data-theme='dark'] .doc-rectangle {
fill: var(--doc-fill-dark);
stroke: var(--doc-stroke-dark);
}
:root[data-theme='dark'] .filius-canvas .devices image {
filter: drop-shadow(0 0 1px rgb(255 255 255 / 55%));
}
/* A focused documentation item's handles (Canvas): a finger's target of
* 44 px around a dot in Java's focus blue, over the field a text is typed
* into; a text's grip a round button above its corner. */
@@ -117,6 +141,26 @@
pointer-events: auto;
}
/* The mode buttons' pictures (ModeBar): Java's in the light scheme; in the
* dark one freed from their grey ground, the button marking the mode that is on. */
.mode-picture {
display: block;
}
.mode-picture[data-scheme='dark'],
:root[data-theme='dark'] .mode-picture[data-scheme='light'] {
display: none;
}
:root[data-theme='dark'] .mode-picture[data-scheme='dark'] {
display: block;
}
:root[data-theme='dark'] .mode-button[aria-pressed='true'] {
border-color: var(--accent) !important;
background: var(--accent-soft) !important;
}
/* A word beside the mode buttons: that this person only reads. */
.editor-note {
display: flex;
+26 -5
View File
@@ -11,8 +11,28 @@ import design from '../assets/modes/entwurfsmodus.png';
import designOn from '../assets/modes/entwurfsmodus_aktiv.png';
import documentation from '../assets/modes/dokumodus.png';
import documentationOn from '../assets/modes/dokumodus_aktiv.png';
import simulationDark from '../assets/modes/aktionsmodus_dunkel.png';
import simulationOnDark from '../assets/modes/aktionsmodus_aktiv_dunkel.png';
import designDark from '../assets/modes/entwurfsmodus_dunkel.png';
import designOnDark from '../assets/modes/entwurfsmodus_aktiv_dunkel.png';
import documentationDark from '../assets/modes/dokumodus_dunkel.png';
import documentationOnDark from '../assets/modes/dokumodus_aktiv_dunkel.png';
import { useTranslate } from '../i18n/i18n.js';
/**
* A mode's picture: Java's (a grey button's gradient, light blue when on),
* and for the dark scheme the same freed from its ground, the button showing
* whether it is on (sheet.css: .mode-picture).
*/
function ModePicture({ light, dark }: { readonly light: string; readonly dark: string }) {
return (
<>
<img className="mode-picture" data-scheme="light" src={light} alt="" width={50} height={50} />
<img className="mode-picture" data-scheme="dark" src={dark} alt="" width={50} height={50} />
</>
);
}
const modeButton: CSSProperties = {
width: 52,
height: 52,
@@ -46,6 +66,7 @@ export function ModeBar({
<button
type="button"
name="documentationMode"
className="mode-button"
style={modeButton}
aria-pressed={mode === 'documentation'}
aria-label={t('mode.documentation')}
@@ -53,13 +74,13 @@ export function ModeBar({
disabled={!canRun && mode === 'simulation'}
onClick={() => onMode('documentation')}
>
<img src={mode === 'documentation' ? documentationOn : documentation} alt="" width={50} height={50} />
<ModePicture light={mode === 'documentation' ? documentationOn : documentation} dark={mode === 'documentation' ? documentationOnDark : documentationDark} />
</button>
<button type="button" name="designMode" style={modeButton} aria-pressed={mode === 'design'} aria-label={t('mode.design')} title={t('mode.design')} disabled={!canRun && mode === 'simulation'} onClick={() => onMode('design')}>
<img src={mode === 'design' ? designOn : design} alt="" width={50} height={50} />
<button type="button" name="designMode" className="mode-button" style={modeButton} aria-pressed={mode === 'design'} aria-label={t('mode.design')} title={t('mode.design')} disabled={!canRun && mode === 'simulation'} onClick={() => onMode('design')}>
<ModePicture light={mode === 'design' ? designOn : design} dark={mode === 'design' ? designOnDark : designDark} />
</button>
<button type="button" name="simulationMode" style={modeButton} aria-pressed={mode === 'simulation'} aria-label={t('mode.simulation')} title={t('mode.simulation')} disabled={!canRun} onClick={() => onMode('simulation')}>
<img src={mode === 'simulation' ? simulationOn : simulation} alt="" width={50} height={50} />
<button type="button" name="simulationMode" className="mode-button" style={modeButton} aria-pressed={mode === 'simulation'} aria-label={t('mode.simulation')} title={t('mode.simulation')} disabled={!canRun} onClick={() => onMode('simulation')}>
<ModePicture light={mode === 'simulation' ? simulationOn : simulation} dark={mode === 'simulation' ? simulationOnDark : simulationDark} />
</button>
{mode === 'simulation' ? (
<label style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '0 8px', minHeight: 52, borderRadius: 8, background: 'var(--surface-raised)', border: '1px solid var(--border-default)' }}>
+48 -6
View File
@@ -45,6 +45,28 @@
--capture-on-layer: #000000;
--capture-chosen: #000000;
--capture-on-chosen: #ffffff;
/* A documentation text's border in the documentation mode (textPanelBorderNormalColor). */
--doc-text-border: rgb(179 179 179);
/* Java's Swing look in the desktop's program windows (Metal): a panel, a field, its border and text, a button, a
* table's head, a chosen row, the focus, a refused entry; the taskbar (GUIDesktopPanel). */
--java-panel: #eeeeee;
--java-field: #ffffff;
--java-text: #000000;
--java-border: #a0a0a0;
--java-border-strong: #808080;
--java-grid: #c0c0c0;
--java-line: #b0b0b0;
--java-rule: #dddddd;
--java-disabled: #a0a0a0;
--java-button: #f8f8f8;
--java-head: #e4e4e4;
--java-tab: #e0e0e0;
--java-selected: #b8cfe5;
--java-focus: rgb(99 130 191);
--java-invalid: #ff0000;
--java-invalid-text: #ff1414;
--java-taskbar: rgb(180 180 180);
color-scheme: light;
}
:root[data-theme='dark'] {
@@ -70,12 +92,13 @@
--danger: #f2695a;
--danger-soft: #3a1a17;
--danger-on-soft: #ffa79b;
/* Java's icons are drawn for a light ground: the surface stays light, a shade darker. */
--canvas-bg: #e9ecf1;
--canvas-cable: rgb(64 64 64);
--canvas-cable-wireless: rgb(150 150 150);
--canvas-cable-active: rgb(0 200 50);
--canvas-label: #000000;
/* The surface dark too, a step above the sheets, so that Java's black switches and routers keep their edge
* (each icon gets a faint light outline, sheet.css); labels and cables light. */
--canvas-bg: #262b33;
--canvas-cable: rgb(150 158 170);
--canvas-cable-wireless: rgb(105 113 125);
--canvas-cable-active: rgb(40 220 90);
--canvas-label: #e6e9ef;
--connection-plugged: rgb(0 190 0);
--connection-free: rgb(210 40 40);
--connection-marked: rgb(235 215 0);
@@ -86,6 +109,25 @@
--capture-on-layer: #e6e9ef;
--capture-chosen: #e6e9ef;
--capture-on-chosen: #14171c;
--doc-text-border: #4a5361;
--java-panel: #20252c;
--java-field: #15181d;
--java-text: #e6e9ef;
--java-border: #4a5361;
--java-border-strong: #5c6573;
--java-grid: #3a414c;
--java-line: #3a414c;
--java-rule: #2e343d;
--java-disabled: #6b7380;
--java-button: #2b3038;
--java-head: #2a2f37;
--java-tab: #262b33;
--java-selected: #2c4a66;
--java-focus: #6f9ad6;
--java-invalid: #f2695a;
--java-invalid-text: #ff8a7d;
--java-taskbar: #2a2f37;
color-scheme: dark;
}
html,
@@ -1,13 +1,14 @@
// How the editor paints a documentation item, against the pixels Java's
// JDocuElement paints (packages/doc/test/documentation.java.json, DocuRun):
// a rectangle in the documentation mode and in the others, its border, the
// text's border; the focused text's frame; where an edge is taken hold of.
// text's border; the focused text's frame; where an edge is taken hold of —
// and the editor's own dark paint, which Java has not.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { edgesAt, fontFamily, holdFrame, rectangleBorder, rectangleFill, TEXT_BORDER, FOCUS_BORDER, FOCUS_BAND, textColor } from '../src/canvas/documentationPaint.js';
import { darkRectangleBorder, darkRectangleFill, darkTextColor, edgesAt, fontFamily, holdFrame, rectangleBorder, rectangleFill, TEXT_BORDER, FOCUS_BORDER, FOCUS_BAND, textColor } from '../src/canvas/documentationPaint.js';
type Rgba = [number, number, number, number];
const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../../doc/test/documentation.java.json'), 'utf8')) as {
@@ -40,6 +41,14 @@ describe("Java's paint of the documentation", () => {
expect(textColor({ red: 0, green: 0, blue: 1, alpha: 0.5 })).toBe('rgba(0, 0, 255, 0.5)');
});
it('paints a rectangle in the dark scheme as a tint of its colour, stronger while documenting, and draws a text towards light', () => {
const grey = { red: 0.8, green: 0.8, blue: 0.8, alpha: 1 };
expect(darkRectangleFill(grey, true)).toBe('rgba(204, 204, 204, 0.28)');
expect(darkRectangleFill(grey, false)).toBe('rgba(204, 204, 204, 0.2)');
expect(darkRectangleBorder({ red: 1, green: 0, blue: 0, alpha: 1 })).toBe('rgba(255, 0, 0, 0.7)');
expect(darkTextColor({ red: 0, green: 0, blue: 0, alpha: 1 })).toBe('color-mix(in oklab, rgba(0, 0, 0, 1) 15%, var(--canvas-label))');
});
it("grows a focused text's frame as Java does, and finds the edges a drag takes", () => {
const [x, y, width, height] = java.dropped.textUnfocused.bounds as [number, number, number, number];
const [fx, fy, fw, fh] = java.dropped.text.bounds as [number, number, number, number];
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""The mode buttons' pictures for the editor's dark scheme (packages/editor/src/assets/modes/*_dunkel.png).
Java's pictures are 50 x 50 buttons: a frame and a vertical grey gradient (light blue and flat when the mode is
on) around the icon. On a dark button that ground would stand as a light tile, so it is freed: the outer ring of
RING pixels goes, and from there every pixel within GROUND of its row's ground colour (the gradient is row by row)
becomes transparent, flooding inwards; a pixel up to EDGE from it (the icon's edge, blended with the grey) keeps its
colour and fades with its likeness. Needs Pillow; run from the repository's root:
python3 tools/dark-pictures/make.py
"""
from collections import deque
from pathlib import Path
from PIL import Image
MODES = Path(__file__).resolve().parents[2] / 'packages/editor/src/assets/modes'
RING = 3
GROUND = 8
EDGE = 48
def freed(picture: Image.Image) -> Image.Image:
image = picture.convert('RGBA')
width, height = image.size
pixels = image.load()
ground = [pixels[RING, y] for y in range(height)]
for y in range(RING):
ground[y] = ground[RING]
for y in range(height - RING, height):
ground[y] = ground[height - RING - 1]
seen = set()
queue = deque()
for x in range(width):
for y in range(height):
if x < RING or y < RING or x >= width - RING or y >= height - RING:
pixels[x, y] = (0, 0, 0, 0)
seen.add((x, y))
queue.extend([(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)])
while queue:
x, y = queue.popleft()
if (x, y) in seen or not (0 <= x < width and 0 <= y < height):
continue
seen.add((x, y))
colour = pixels[x, y]
distance = max(abs(colour[index] - ground[y][index]) for index in range(3))
if distance <= GROUND:
pixels[x, y] = (*colour[:3], 0)
queue.extend([(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)])
elif distance <= EDGE:
pixels[x, y] = (*colour[:3], min(255, int(255 * (distance - GROUND) / (EDGE - GROUND))))
return image
if __name__ == '__main__':
for source in sorted(MODES.glob('*.png')):
if source.stem.endswith('_dunkel'):
continue
freed(Image.open(source)).save(source.with_name(f'{source.stem}_dunkel.png'))
print(source.with_name(f'{source.stem}_dunkel.png').name)