@miragon/modeler-shell 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +1902 -0
- package/LICENSE +21 -0
- package/README.md +435 -2
- package/THIRD_PARTY_NOTICES.md +52 -0
- package/dist/appearance-D3FUcbu6.d.cts +53 -0
- package/dist/appearance-D3FUcbu6.d.ts +53 -0
- package/dist/chunk-5JSZ4VGA.js +551 -0
- package/dist/chunk-5JSZ4VGA.js.map +1 -0
- package/dist/chunk-6LQOFACK.js +1576 -0
- package/dist/chunk-6LQOFACK.js.map +1 -0
- package/dist/chunk-N4QXFF3A.js +23 -0
- package/dist/chunk-N4QXFF3A.js.map +1 -0
- package/dist/chunk-RJRFR6N3.js +136 -0
- package/dist/chunk-RJRFR6N3.js.map +1 -0
- package/dist/core.cjs +619 -0
- package/dist/core.cjs.map +1 -0
- package/dist/core.d.cts +171 -0
- package/dist/core.d.ts +171 -0
- package/dist/core.js +91 -0
- package/dist/core.js.map +1 -0
- package/dist/diagram-js.cjs +1115 -0
- package/dist/diagram-js.cjs.map +1 -0
- package/dist/diagram-js.d.cts +163 -0
- package/dist/diagram-js.d.ts +163 -0
- package/dist/diagram-js.js +867 -0
- package/dist/diagram-js.js.map +1 -0
- package/dist/index.cjs +2175 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +20 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +103 -0
- package/dist/index.js.map +1 -0
- package/dist/keymap-CObQE6q6.d.cts +151 -0
- package/dist/keymap-DORzHnK-.d.ts +151 -0
- package/dist/protocol-DhYBakWL.d.cts +15 -0
- package/dist/protocol-IeW8bf4f.d.ts +15 -0
- package/dist/shell.css +914 -0
- package/dist/testing-vscode-stub.cjs +332 -0
- package/dist/testing-vscode-stub.cjs.map +1 -0
- package/dist/testing-vscode-stub.d.cts +65 -0
- package/dist/testing-vscode-stub.d.ts +65 -0
- package/dist/testing-vscode-stub.js +154 -0
- package/dist/testing-vscode-stub.js.map +1 -0
- package/dist/testing.cjs +2364 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +165 -0
- package/dist/testing.d.ts +165 -0
- package/dist/testing.js +2297 -0
- package/dist/testing.js.map +1 -0
- package/dist/types-B6NfkiUG.d.cts +66 -0
- package/dist/types-B6NfkiUG.d.ts +66 -0
- package/dist/types-DnZn9CPw.d.ts +284 -0
- package/dist/types-OuFlqZp0.d.cts +284 -0
- package/dist/vscode-extension.cjs +215 -0
- package/dist/vscode-extension.cjs.map +1 -0
- package/dist/vscode-extension.d.cts +103 -0
- package/dist/vscode-extension.d.ts +103 -0
- package/dist/vscode-extension.js +24 -0
- package/dist/vscode-extension.js.map +1 -0
- package/dist/vscode-webview.cjs +2198 -0
- package/dist/vscode-webview.cjs.map +1 -0
- package/dist/vscode-webview.d.cts +82 -0
- package/dist/vscode-webview.d.ts +82 -0
- package/dist/vscode-webview.js +250 -0
- package/dist/vscode-webview.js.map +1 -0
- package/package.json +145 -4
- package/src/core/appearance.ts +104 -0
- package/src/core/fit.ts +205 -0
- package/src/core/index.ts +84 -0
- package/src/core/keymap.ts +275 -0
- package/src/core/layout.ts +69 -0
- package/src/core/messages.ts +88 -0
- package/src/core/sizes.ts +85 -0
- package/src/core/types.ts +84 -0
- package/src/core/zoom.ts +74 -0
- package/src/core.ts +2 -0
- package/src/diagram-js/connect.ts +75 -0
- package/src/diagram-js/index.ts +16 -0
- package/src/diagram-js/module.ts +20 -0
- package/src/diagram-js/palette-skin.ts +267 -0
- package/src/diagram-js/palette.css +269 -0
- package/src/diagram-js/service.ts +240 -0
- package/src/diagram-js/types.ts +67 -0
- package/src/diagram-js/viewport.ts +564 -0
- package/src/diagram-js.ts +2 -0
- package/src/dom/element.ts +51 -0
- package/src/dom/hint.ts +51 -0
- package/src/dom/icons.ts +309 -0
- package/src/dom/index.ts +29 -0
- package/src/dom/inspector.ts +35 -0
- package/src/dom/keys.ts +59 -0
- package/src/dom/ledger.ts +87 -0
- package/src/dom/legend.ts +85 -0
- package/src/dom/menu.ts +393 -0
- package/src/dom/mount.ts +461 -0
- package/src/dom/problems.ts +72 -0
- package/src/dom/shell-dom.css +966 -0
- package/src/dom/status.ts +72 -0
- package/src/dom/tooltip.ts +237 -0
- package/src/dom/types.ts +277 -0
- package/src/dom/zoom-panel.ts +154 -0
- package/src/index.ts +3 -0
- package/src/shell.css +6 -0
- package/src/testing/cases.ts +1012 -0
- package/src/testing/checks.ts +832 -0
- package/src/testing/facts.ts +587 -0
- package/src/testing/geometry.ts +472 -0
- package/src/testing/harness.ts +138 -0
- package/src/testing/index.ts +15 -0
- package/src/testing/page-tools.ts +434 -0
- package/src/testing/page.ts +17 -0
- package/src/testing/vscode-stub.ts +237 -0
- package/src/testing-vscode-stub.ts +2 -0
- package/src/testing.ts +2 -0
- package/src/vscode/extension/html.ts +107 -0
- package/src/vscode/extension/index.ts +8 -0
- package/src/vscode/extension/reveal.ts +9 -0
- package/src/vscode/extension/theme-bridge.ts +85 -0
- package/src/vscode/extension/types.ts +36 -0
- package/src/vscode/protocol.ts +31 -0
- package/src/vscode/webview/appearance.ts +141 -0
- package/src/vscode/webview/index.ts +8 -0
- package/src/vscode/webview/mount.ts +76 -0
- package/src/vscode/webview/state.ts +116 -0
- package/src/vscode-extension.ts +2 -0
- package/src/vscode-webview.ts +2 -0
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
import { INVALID_STATUS_DELAY_MS, type Appearance, type ShellHost, type Theme } from "../core/index.js";
|
|
2
|
+
import {
|
|
3
|
+
DOM_ORDER,
|
|
4
|
+
type AppearanceFacts,
|
|
5
|
+
type AriaFacts,
|
|
6
|
+
type CanvasStateFacts,
|
|
7
|
+
type MenuEntryFacts,
|
|
8
|
+
type MenuFacts,
|
|
9
|
+
type PaletteColorEntry,
|
|
10
|
+
type PaletteColorFacts,
|
|
11
|
+
type PaletteEntryState,
|
|
12
|
+
type ReadOnlyStateFacts,
|
|
13
|
+
type ShapeFacts,
|
|
14
|
+
type SurroundFacts,
|
|
15
|
+
type ZoomButton,
|
|
16
|
+
} from "./facts.js";
|
|
17
|
+
import { GEOMETRY_TOLERANCE_PX, type Rectangle } from "./geometry.js";
|
|
18
|
+
import type { ShellConformanceHarness, ShowHook } from "./harness.js";
|
|
19
|
+
import type { ElementFacts, LeftoverFacts, RecordedKey } from "./page-tools.js";
|
|
20
|
+
import type { PageLike } from "./page.js";
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
* The judgements of the cases, as pure functions of what the probes saw. Each returns readable
|
|
24
|
+
* problems (empty = conforming), so a case can report all of them at once.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const normalize = (text: string): string => text.replace(/\s+/g, " ").trim();
|
|
28
|
+
|
|
29
|
+
const name = (element: ElementFacts): string => normalize(element.attributes["aria-label"] ?? element.text);
|
|
30
|
+
|
|
31
|
+
const attribute = (element: ElementFacts, key: string): string | undefined => element.attributes[key];
|
|
32
|
+
|
|
33
|
+
/** `aria-keyshortcuts` of the zoom panel (guide §5.6, WAI-ARIA key names). */
|
|
34
|
+
export function expectedKeyShortcuts(platform: "mac" | "other"): Record<ZoomButton, string> {
|
|
35
|
+
const modifier = platform === "mac" ? "Meta" : "Control";
|
|
36
|
+
return {
|
|
37
|
+
in: `${modifier}+= ${modifier}+Plus`,
|
|
38
|
+
out: `${modifier}+-`,
|
|
39
|
+
actual: `${modifier}+0`,
|
|
40
|
+
fit: "Shift+1",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Roles, names, states, DOM order and tab stops of guide §5.4 to §5.14 and §12. */
|
|
45
|
+
export function ariaProblems(facts: AriaFacts, host: ShellHost): string[] {
|
|
46
|
+
const problems: string[] = [];
|
|
47
|
+
|
|
48
|
+
if (host === "embedded") {
|
|
49
|
+
if (!facts.rootLang)
|
|
50
|
+
problems.push("root: an embedded shell root carries the tool's lang itself (guide §2, §12)");
|
|
51
|
+
} else if (!facts.documentLang) {
|
|
52
|
+
problems.push("<html> has no lang (guide §2)");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const trigger = facts.trigger;
|
|
56
|
+
if (!trigger) {
|
|
57
|
+
problems.push('menu: no [data-chrome="menu"] trigger');
|
|
58
|
+
} else {
|
|
59
|
+
if (trigger.tag !== "button")
|
|
60
|
+
problems.push(`menu: the trigger is a <${trigger.tag}>, expected a <button>`);
|
|
61
|
+
if (!attribute(trigger, "aria-label")) problems.push("menu: the icon-only trigger has no aria-label");
|
|
62
|
+
if (attribute(trigger, "aria-haspopup") !== "menu") {
|
|
63
|
+
problems.push(`menu: aria-haspopup is "${attribute(trigger, "aria-haspopup") ?? ""}", expected "menu"`);
|
|
64
|
+
}
|
|
65
|
+
if (attribute(trigger, "aria-expanded") !== "false") {
|
|
66
|
+
problems.push(
|
|
67
|
+
`menu: aria-expanded is "${attribute(trigger, "aria-expanded") ?? ""}" while closed, expected "false"`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (!attribute(trigger, "aria-controls")) {
|
|
71
|
+
problems.push("menu: the trigger has no aria-controls");
|
|
72
|
+
} else if (!facts.controlled) {
|
|
73
|
+
problems.push(`menu: aria-controls="${attribute(trigger, "aria-controls")}" names no element`);
|
|
74
|
+
} else if (attribute(facts.controlled, "role") !== "menu") {
|
|
75
|
+
problems.push(
|
|
76
|
+
`menu: aria-controls names a ${facts.controlled.description} with role "${attribute(facts.controlled, "role") ?? ""}", expected "menu"`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!facts.palette) {
|
|
82
|
+
problems.push('palette: no [data-chrome="palette"]');
|
|
83
|
+
} else {
|
|
84
|
+
const toolbar = facts.toolbar;
|
|
85
|
+
if (!toolbar || attribute(toolbar, "role") !== "toolbar") {
|
|
86
|
+
problems.push('palette: .djs-palette-entries has no role="toolbar"');
|
|
87
|
+
} else if (!attribute(toolbar, "aria-label")) {
|
|
88
|
+
problems.push("palette: the toolbar has no aria-label");
|
|
89
|
+
}
|
|
90
|
+
for (const entry of facts.entries) {
|
|
91
|
+
const action = attribute(entry, "data-action") ?? entry.description;
|
|
92
|
+
if (attribute(entry, "role") !== "button")
|
|
93
|
+
problems.push(`palette: entry "${action}" has no role="button"`);
|
|
94
|
+
if (!attribute(entry, "aria-label"))
|
|
95
|
+
problems.push(`palette: entry "${action}" has no aria-label (title)`);
|
|
96
|
+
const tabindex = attribute(entry, "tabindex");
|
|
97
|
+
if (tabindex !== "0" && tabindex !== "-1") {
|
|
98
|
+
problems.push(`palette: entry "${action}" has tabindex "${tabindex ?? ""}", expected roving 0/-1`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const stops = facts.entries.filter((entry) => attribute(entry, "tabindex") === "0");
|
|
102
|
+
if (facts.entries.length > 0 && stops.length !== 1) {
|
|
103
|
+
problems.push(
|
|
104
|
+
`palette: ${stops.length} entries with tabindex="0", expected exactly one (roving tabindex)`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const tools = facts.entries.filter(
|
|
108
|
+
(entry) => entry.group === "tools" && (attribute(entry, "data-action") ?? "").endsWith("-tool"),
|
|
109
|
+
);
|
|
110
|
+
for (const tool of tools) {
|
|
111
|
+
const pressed = attribute(tool, "aria-pressed");
|
|
112
|
+
if (pressed !== "true" && pressed !== "false") {
|
|
113
|
+
problems.push(`palette: tool "${attribute(tool, "data-action")}" has no aria-pressed`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const pressed = tools.filter((tool) => attribute(tool, "aria-pressed") === "true");
|
|
117
|
+
if (pressed.length > 1) problems.push(`palette: ${pressed.length} tools are aria-pressed at once`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const zoom = facts.zoom;
|
|
121
|
+
if (!zoom) {
|
|
122
|
+
problems.push('zoom: no [data-chrome="zoom"]');
|
|
123
|
+
} else {
|
|
124
|
+
if (attribute(zoom, "role") !== "group") problems.push('zoom: the panel has no role="group"');
|
|
125
|
+
if (!attribute(zoom, "aria-label")) problems.push("zoom: the panel has no aria-label");
|
|
126
|
+
const shortcuts = expectedKeyShortcuts(facts.platform);
|
|
127
|
+
for (const button of ["out", "actual", "in", "fit"] as const) {
|
|
128
|
+
const control = facts.zoomButtons[button];
|
|
129
|
+
if (!control) {
|
|
130
|
+
problems.push(`zoom: no [data-zoom="${button}"] button`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (control.tag !== "button") problems.push(`zoom: [data-zoom="${button}"] is a <${control.tag}>`);
|
|
134
|
+
if (!name(control)) problems.push(`zoom: [data-zoom="${button}"] has no accessible name`);
|
|
135
|
+
if (attribute(control, "aria-keyshortcuts") !== shortcuts[button]) {
|
|
136
|
+
problems.push(
|
|
137
|
+
`zoom: [data-zoom="${button}"] aria-keyshortcuts is "${attribute(control, "aria-keyshortcuts") ?? ""}", expected "${shortcuts[button]}" (${facts.platform})`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (control.disabled) {
|
|
141
|
+
problems.push(
|
|
142
|
+
`zoom: [data-zoom="${button}"] is natively disabled; aria-disabled keeps the focus (guide §5.8)`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const readout = facts.zoomButtons.actual;
|
|
147
|
+
if (readout) {
|
|
148
|
+
if (!/\d/.test(readout.text))
|
|
149
|
+
problems.push(`zoom: the readout shows "${readout.text}", expected a percentage`);
|
|
150
|
+
const label = normalize(attribute(readout, "aria-label") ?? "");
|
|
151
|
+
if (!label.includes(readout.text)) {
|
|
152
|
+
problems.push(
|
|
153
|
+
`zoom: the readout's aria-label "${label}" does not name the current zoom "${readout.text}"`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (attribute(readout, "aria-disabled") === "true")
|
|
157
|
+
problems.push("zoom: the readout is never disabled");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const present = new Set(facts.order);
|
|
162
|
+
const expectedOrder = DOM_ORDER.filter((part) => present.has(part));
|
|
163
|
+
if (facts.order.join() !== expectedOrder.join()) {
|
|
164
|
+
problems.push(
|
|
165
|
+
`DOM order is ${facts.order.join(", ")}; expected ${expectedOrder.join(", ")} (guide §5.14)`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (!present.has("canvas")) problems.push("canvas: no diagram-js <svg> (.djs-container > svg) in the root");
|
|
169
|
+
const canvas = facts.canvas;
|
|
170
|
+
if (canvas && !normalize(attribute(canvas, "aria-label") ?? "") && !attribute(canvas, "aria-labelledby")) {
|
|
171
|
+
problems.push(
|
|
172
|
+
'canvas: the focusable <svg> has no accessible name (WCAG 4.1.2); shellModule names it role="application" with messages.canvas',
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
for (const element of facts.positiveTabindex)
|
|
176
|
+
problems.push(`positive tabindex on ${element} (guide §5.14)`);
|
|
177
|
+
for (const element of facts.titled) {
|
|
178
|
+
problems.push(`${element} has a native title; the chrome tooltip replaces it (guide §5.6)`);
|
|
179
|
+
}
|
|
180
|
+
return problems;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The optional islands once the harness showed them. */
|
|
184
|
+
export function optionalIslandProblems(facts: AriaFacts, shown: ReadonlySet<string>): string[] {
|
|
185
|
+
const problems: string[] = [];
|
|
186
|
+
if (shown.has("problems")) {
|
|
187
|
+
const pill = facts.problems;
|
|
188
|
+
if (!pill?.visible) {
|
|
189
|
+
problems.push("problems: the pill is not visible after show.problems");
|
|
190
|
+
} else {
|
|
191
|
+
if (pill.tag !== "button") problems.push(`problems: the pill is a <${pill.tag}>, expected a <button>`);
|
|
192
|
+
const label = normalize(attribute(pill, "aria-label") ?? "");
|
|
193
|
+
if (!label) problems.push("problems: the pill has no aria-label (count and errors)");
|
|
194
|
+
else if (!label.includes(pill.text)) {
|
|
195
|
+
problems.push(`problems: the aria-label "${label}" does not contain the visible text "${pill.text}"`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (shown.has("status")) {
|
|
200
|
+
const status = facts.status;
|
|
201
|
+
if (!status?.visible) {
|
|
202
|
+
problems.push("status: the status box is not visible after show.status");
|
|
203
|
+
} else {
|
|
204
|
+
const role = attribute(status, "role");
|
|
205
|
+
const severity = attribute(status, "data-severity");
|
|
206
|
+
const expected = severity === "danger" ? "alert" : "status";
|
|
207
|
+
if (role !== expected) {
|
|
208
|
+
problems.push(
|
|
209
|
+
`status: role is "${role ?? ""}" for data-severity="${severity ?? ""}", expected "${expected}"`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (shown.has("legend")) {
|
|
215
|
+
const legend = facts.legend;
|
|
216
|
+
if (!legend?.visible) {
|
|
217
|
+
problems.push("legend: the legend is not visible after show.legend");
|
|
218
|
+
} else {
|
|
219
|
+
if (!facts.legendTitle)
|
|
220
|
+
problems.push("legend: the card is not labelled by its title (aria-labelledby)");
|
|
221
|
+
if (!facts.legendClose || !attribute(facts.legendClose, "aria-label")) {
|
|
222
|
+
problems.push("legend: the close button has no aria-label");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (shown.has("inspector")) {
|
|
227
|
+
const inspector = facts.inspector;
|
|
228
|
+
if (!inspector?.visible) {
|
|
229
|
+
problems.push("inspector: the inspector is not visible after show.inspector");
|
|
230
|
+
} else {
|
|
231
|
+
if (inspector.tag !== "aside") problems.push(`inspector: a <${inspector.tag}>, expected an <aside>`);
|
|
232
|
+
if (!attribute(inspector, "aria-label")) problems.push("inspector: the <aside> has no aria-label");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return problems;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** One Tab stop as the kit's tab-sequence probe saw it. */
|
|
239
|
+
export interface TabStop {
|
|
240
|
+
description: string;
|
|
241
|
+
/** What the stop is: the palette's roving entry, the canvas `<svg>`, the web app's primary action. */
|
|
242
|
+
kind: "palette" | "canvas" | "primary" | "other";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Tab from the menu trigger (guide §5.14): the primary action (web app), the palette's one tab
|
|
247
|
+
* stop, then the canvas `<svg>`; nothing else in between (a renderer's own `tabindex` on
|
|
248
|
+
* `.djs-container` would be an extra, unnamed stop).
|
|
249
|
+
*/
|
|
250
|
+
export function tabSequenceProblems(stops: readonly TabStop[]): string[] {
|
|
251
|
+
const [first, second, third] = stops;
|
|
252
|
+
const sequence = first?.kind === "primary" ? [second, third] : [first, second];
|
|
253
|
+
const [palette, canvas] = sequence;
|
|
254
|
+
const problems: string[] = [];
|
|
255
|
+
if (palette?.kind !== "palette") {
|
|
256
|
+
problems.push(
|
|
257
|
+
`tab order: Tab from the menu trigger lands on ${palette?.description ?? "nothing"}, expected the palette's roving entry (guide §5.14)`,
|
|
258
|
+
);
|
|
259
|
+
} else if (canvas?.kind !== "canvas") {
|
|
260
|
+
problems.push(
|
|
261
|
+
`tab order: Tab from the palette lands on ${canvas?.description ?? "nothing"}, expected the canvas <svg> (an unexpected tab stop, guide §5.14)`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return problems;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** A palette entry with the keyboard focus: its tooltip's chip and what it names for assistive technology. */
|
|
268
|
+
export interface PaletteShortcutFacts {
|
|
269
|
+
action: string;
|
|
270
|
+
/** Text of the visible tooltip's `kbd` chip; empty without a chip. */
|
|
271
|
+
chip: string;
|
|
272
|
+
keyShortcuts: string | null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Guide §5.6: an entry whose tooltip shows a shortcut carries `aria-keyshortcuts`. */
|
|
276
|
+
export function paletteShortcutProblems(entries: readonly PaletteShortcutFacts[]): string[] {
|
|
277
|
+
return entries
|
|
278
|
+
.filter((entry) => entry.chip && !entry.keyShortcuts)
|
|
279
|
+
.map(
|
|
280
|
+
(entry) =>
|
|
281
|
+
`palette: entry "${entry.action}" shows the shortcut "${entry.chip}" in its tooltip but has no aria-keyshortcuts (guide §5.6)`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The keys an open menu consumes (↑ ↓ Home End, a letter) never reach a `keydown` listener on
|
|
287
|
+
* `document`: a modeler's document keyboard would act on them (Event Storming moves the selection
|
|
288
|
+
* on ↑/↓).
|
|
289
|
+
*/
|
|
290
|
+
export function menuKeysAtDocumentProblems(
|
|
291
|
+
pressed: readonly string[],
|
|
292
|
+
documentKeys: readonly RecordedKey[],
|
|
293
|
+
): string[] {
|
|
294
|
+
const reached = new Set(documentKeys.map((key) => key.key));
|
|
295
|
+
return pressed
|
|
296
|
+
.filter((key) => reached.has(key))
|
|
297
|
+
.map(
|
|
298
|
+
(key) =>
|
|
299
|
+
`menu: ${key} in the open menu reached a keydown listener on document; a modeler's document keyboard acts on it (stop it at the menu, guide §5.4)`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** An open menu (guide §5.4): expanded trigger, labelled `role="menu"`, named items, focus. */
|
|
304
|
+
export function menuOpenProblems(facts: MenuFacts, focus?: "first" | "last"): string[] {
|
|
305
|
+
const problems: string[] = [];
|
|
306
|
+
if (facts.expanded !== "true") {
|
|
307
|
+
problems.push(`menu: aria-expanded is "${facts.expanded ?? ""}" while open, expected "true"`);
|
|
308
|
+
}
|
|
309
|
+
const menu = facts.menu;
|
|
310
|
+
if (!menu) return [...problems, 'menu: no visible role="menu" after opening'];
|
|
311
|
+
if (!attribute(menu, "aria-label")) problems.push("menu: the dropdown has no aria-label");
|
|
312
|
+
if (facts.controls && attribute(menu, "id") !== facts.controls) {
|
|
313
|
+
problems.push(
|
|
314
|
+
`menu: the visible dropdown is #${attribute(menu, "id") ?? ""}, the trigger controls #${facts.controls}`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
if (facts.items.length === 0) problems.push("menu: the dropdown has no menuitem");
|
|
318
|
+
facts.items.forEach((item, index) => {
|
|
319
|
+
const label = item.name || `item ${index + 1}`;
|
|
320
|
+
if (!item.name) problems.push(`menu: ${label} has no accessible name`);
|
|
321
|
+
if (item.role !== "menuitem" && item.checked !== "true" && item.checked !== "false") {
|
|
322
|
+
problems.push(`menu: "${label}" (${item.role}) has aria-checked "${item.checked ?? ""}"`);
|
|
323
|
+
}
|
|
324
|
+
if (item.role === "menuitemradio" && !item.group) {
|
|
325
|
+
problems.push(`menu: radio "${label}" is not in a labelled role="group"`);
|
|
326
|
+
}
|
|
327
|
+
if (item.haspopup === "dialog" && !item.name.endsWith("…")) {
|
|
328
|
+
problems.push(`menu: "${label}" opens a dialog, so its label ends with "…" (U+2026)`);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
if (focus && facts.items.length > 0) {
|
|
332
|
+
const expected = focus === "first" ? 0 : facts.items.length - 1;
|
|
333
|
+
if (facts.active !== expected) {
|
|
334
|
+
problems.push(
|
|
335
|
+
`menu: focus is on ${facts.active < 0 ? "no entry" : `entry ${facts.active + 1}`}, expected the ${focus} entry`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return problems;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function menuClosedProblems(facts: MenuFacts, focusOnTrigger: boolean): string[] {
|
|
343
|
+
const problems: string[] = [];
|
|
344
|
+
if (facts.expanded !== "false")
|
|
345
|
+
problems.push(`menu: aria-expanded is "${facts.expanded ?? ""}" after closing`);
|
|
346
|
+
if (facts.menu) problems.push("menu: the dropdown is still visible after Escape");
|
|
347
|
+
if (focusOnTrigger && !facts.activeIsTrigger)
|
|
348
|
+
problems.push("menu: Escape did not return the focus to the trigger");
|
|
349
|
+
return problems;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* How a key press must arrive at a `keydown` listener on `window` (guide §5.8, §13.5):
|
|
354
|
+
* - `handled`: the shell executes it; IDE: stopped, else it arrives prevented;
|
|
355
|
+
* - `blocked`: recognized but not executed (text field, modal, Escape with a dialog); IDE: stopped,
|
|
356
|
+
* else it arrives unprevented (the browser, the field or the dialog acts);
|
|
357
|
+
* - `canvas`: the canvas handled it (`defaultPrevented`); IDE: stopped, else it arrives prevented;
|
|
358
|
+
* - `free`: nobody may handle it; it arrives unprevented in every host.
|
|
359
|
+
* In the IDE host a stopped key never reaches `window`, so `prevented` is read on `document`.
|
|
360
|
+
*/
|
|
361
|
+
export type KeyReach = "handled" | "blocked" | "canvas" | "free";
|
|
362
|
+
|
|
363
|
+
export interface SeenKeys {
|
|
364
|
+
/** What a listener on `window` saw. */
|
|
365
|
+
keys: readonly RecordedKey[];
|
|
366
|
+
/** What a listener on `document`, registered after the shell's, saw. */
|
|
367
|
+
documentKeys?: readonly RecordedKey[];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function keyReachProblem(
|
|
371
|
+
press: string,
|
|
372
|
+
reach: KeyReach,
|
|
373
|
+
host: ShellHost,
|
|
374
|
+
seen: SeenKeys,
|
|
375
|
+
): string | undefined {
|
|
376
|
+
const shouldPrevent = reach === "handled" || reach === "canvas";
|
|
377
|
+
if (host === "ide" && reach !== "free") {
|
|
378
|
+
if (seen.keys.length > 0) {
|
|
379
|
+
return `${press} reached a keydown listener on window; in the IDE host the shell stops it on document, or VS Code runs it again (guide §13.5)`;
|
|
380
|
+
}
|
|
381
|
+
const atDocument = seen.documentKeys ?? [];
|
|
382
|
+
const prevented = atDocument.length === 1 ? atDocument[0]?.defaultPrevented : undefined;
|
|
383
|
+
if (prevented === true && !shouldPrevent) {
|
|
384
|
+
return `${press} was prevented; the shell only stops it, so the field, dialog or browser still acts (guide §13.5)`;
|
|
385
|
+
}
|
|
386
|
+
if (prevented === false && shouldPrevent) {
|
|
387
|
+
return `${press} was stopped but not prevented; a handled key also calls preventDefault`;
|
|
388
|
+
}
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
if (seen.keys.length === 0) {
|
|
392
|
+
return host === "ide"
|
|
393
|
+
? `${press} never reached window; keys the webview does not handle must go on to VS Code (guide §13.5)`
|
|
394
|
+
: `${press} never reached window; outside the IDE host the shell never stops propagation`;
|
|
395
|
+
}
|
|
396
|
+
if (seen.keys.length > 1) return `${press} reached window ${seen.keys.length} times`;
|
|
397
|
+
const prevented = seen.keys[0]?.defaultPrevented ?? false;
|
|
398
|
+
if (prevented && !shouldPrevent) {
|
|
399
|
+
return `${press} arrived prevented; ${reach === "free" ? "nothing may handle it" : "the browser, the field or the dialog must act"}`;
|
|
400
|
+
}
|
|
401
|
+
if (!prevented && shouldPrevent) {
|
|
402
|
+
return `${press} arrived unprevented; a handled key calls preventDefault (the browser would zoom the page)`;
|
|
403
|
+
}
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** The decision of guide §13.5, computed independently of the shell under test. */
|
|
408
|
+
export function expectedAppearance(requested: Appearance, themes: readonly Theme[]): Appearance {
|
|
409
|
+
const fallback = themes[0];
|
|
410
|
+
if (fallback === undefined) throw new Error("@miragon/modeler-shell conformance: harness.themes is empty");
|
|
411
|
+
const theme = themes.includes(requested.theme) ? requested.theme : fallback;
|
|
412
|
+
return requested.kontrast ? { theme, kontrast: requested.kontrast } : { theme };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const describeAppearance = (appearance: Appearance): string =>
|
|
416
|
+
appearance.kontrast ? `${appearance.theme} + ${appearance.kontrast}` : appearance.theme;
|
|
417
|
+
|
|
418
|
+
export interface AppearanceVerdict {
|
|
419
|
+
problems: string[];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* What an embedded root must show (guide §13.3): until the contract's `appearance` surface there
|
|
424
|
+
* is no channel to ask it for a mode, so it stays in its first one whatever the page shows, and
|
|
425
|
+
* takes the page's high contrast over from the nearest mode carrier above it.
|
|
426
|
+
*/
|
|
427
|
+
export function expectedEmbeddedAppearance(facts: AppearanceFacts, themes: readonly Theme[]): Appearance {
|
|
428
|
+
const fallback = themes[0];
|
|
429
|
+
if (fallback === undefined) throw new Error("@miragon/modeler-shell conformance: harness.themes is empty");
|
|
430
|
+
const host = facts.themed.filter((element) => element.relation === "ancestor").at(-1);
|
|
431
|
+
return host?.kontrast === "hoch" ? { theme: fallback, kontrast: "hoch" } : { theme: fallback };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** No half-dark (guide §13.3, §13.5): one mode on `<html>` (own document) or the root (embedded). */
|
|
435
|
+
export function appearanceProblems(
|
|
436
|
+
facts: AppearanceFacts,
|
|
437
|
+
host: ShellHost,
|
|
438
|
+
requested: Appearance,
|
|
439
|
+
themes: readonly Theme[],
|
|
440
|
+
): AppearanceVerdict {
|
|
441
|
+
const ownDocument = host !== "embedded";
|
|
442
|
+
const expected = ownDocument
|
|
443
|
+
? expectedAppearance(requested, themes)
|
|
444
|
+
: expectedEmbeddedAppearance(facts, themes);
|
|
445
|
+
const problems: string[] = [];
|
|
446
|
+
const label = ownDocument
|
|
447
|
+
? `requested ${describeAppearance(requested)}, expected ${describeAppearance(expected)}`
|
|
448
|
+
: `host page ${describeAppearance(requested)}, expected ${describeAppearance(expected)}`;
|
|
449
|
+
|
|
450
|
+
const carrier = ownDocument
|
|
451
|
+
? facts.themed.find((element) => element.isDocument && element.theme !== null)
|
|
452
|
+
: facts.themed.find((element) => element.relation === "self" && element.theme !== null);
|
|
453
|
+
if (!carrier) {
|
|
454
|
+
problems.push(
|
|
455
|
+
ownDocument
|
|
456
|
+
? `<html> has no data-cd-theme (${label}); the host always writes it, also "light" (guide §13.3)`
|
|
457
|
+
: `the embedded root has no data-cd-theme (${label}): it takes the host page's mode instead of its own (guide §13.3)`,
|
|
458
|
+
);
|
|
459
|
+
} else {
|
|
460
|
+
if (carrier.theme !== expected.theme) {
|
|
461
|
+
problems.push(`${carrier.description} has data-cd-theme="${carrier.theme}" (${label})`);
|
|
462
|
+
}
|
|
463
|
+
if ((carrier.kontrast ?? undefined) !== expected.kontrast) {
|
|
464
|
+
problems.push(
|
|
465
|
+
`${carrier.description} has ${carrier.kontrast ? `data-cd-kontrast="${carrier.kontrast}"` : "no data-cd-kontrast"} (${label})`,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
for (const element of facts.themed) {
|
|
470
|
+
if (element === carrier) continue;
|
|
471
|
+
const stray = ownDocument
|
|
472
|
+
? !element.isDocument || element.theme === null
|
|
473
|
+
: element.relation === "descendant" || element.relation === "self";
|
|
474
|
+
if (!stray) continue;
|
|
475
|
+
const values = [
|
|
476
|
+
element.theme !== null ? `data-cd-theme="${element.theme}"` : "",
|
|
477
|
+
element.kontrast !== null ? `data-cd-kontrast="${element.kontrast}"` : "",
|
|
478
|
+
]
|
|
479
|
+
.filter(Boolean)
|
|
480
|
+
.join(" ");
|
|
481
|
+
const sameMode =
|
|
482
|
+
carrier !== undefined && element.theme === carrier.theme && element.kontrast === carrier.kontrast;
|
|
483
|
+
problems.push(
|
|
484
|
+
element.theme === null
|
|
485
|
+
? `${element.description} carries ${values} without data-cd-theme on the same element (guide §13.3)`
|
|
486
|
+
: sameMode
|
|
487
|
+
? `${element.description} repeats ${values}: in an own document the attributes stand only on <html> (guide §13.5)`
|
|
488
|
+
: `${element.description} carries ${values}: a second mode inside the modeler is half-dark (guide §13.5)`,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (ownDocument && !facts.documentClasses.includes("cd-shell-document")) {
|
|
493
|
+
problems.push(
|
|
494
|
+
"<html> lacks cd-shell-document in an own document (the reset of the webview's host styles)",
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
if (!ownDocument && facts.documentClasses.includes("cd-shell-document")) {
|
|
498
|
+
problems.push(
|
|
499
|
+
"<html> of a foreign page carries cd-shell-document: the own-document reset restyles the host page",
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const reference = facts.parts[0];
|
|
504
|
+
if (reference && !reference.roles["--cd-ui-text"]) {
|
|
505
|
+
problems.push(
|
|
506
|
+
"--cd-ui-text is empty at the shell root: the vendored cd-tokens.generated.css is not loaded",
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
for (const part of facts.parts) {
|
|
510
|
+
const schemes = part.colorScheme.split(/\s+/);
|
|
511
|
+
const dark = schemes.includes("dark");
|
|
512
|
+
const light = schemes.includes("light");
|
|
513
|
+
if (expected.theme === "light" && (dark || (carrier && !light))) {
|
|
514
|
+
problems.push(`${part.name}: color-scheme is "${part.colorScheme}" (${label})`);
|
|
515
|
+
}
|
|
516
|
+
if (expected.theme === "dark" && (!dark || light)) {
|
|
517
|
+
problems.push(`${part.name}: color-scheme is "${part.colorScheme}" (${label})`);
|
|
518
|
+
}
|
|
519
|
+
if (reference && part !== reference) {
|
|
520
|
+
for (const [role, value] of Object.entries(part.roles)) {
|
|
521
|
+
if (value !== reference.roles[role]) {
|
|
522
|
+
problems.push(
|
|
523
|
+
`${part.name}: ${role} resolves to "${value}", the root to "${reference.roles[role] ?? ""}" (half-dark)`,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
for (const required of ["dropdown", "tooltip"]) {
|
|
530
|
+
if (!facts.parts.some((part) => part.name === required)) {
|
|
531
|
+
problems.push(`${required}: not visible when the kit opened it, so its mode was not checked`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return { problems };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** The root attributes the shell writes (API.md, DOM). */
|
|
538
|
+
const SHELL_ROOT_ATTRIBUTES = [
|
|
539
|
+
"data-cd-shell-host",
|
|
540
|
+
"data-palette",
|
|
541
|
+
"data-legend",
|
|
542
|
+
"data-inspector",
|
|
543
|
+
"data-problems",
|
|
544
|
+
"data-readonly",
|
|
545
|
+
];
|
|
546
|
+
|
|
547
|
+
/** What an embedded root carries on top (its own mode). */
|
|
548
|
+
const EMBEDDED_ROOT_ATTRIBUTES = ["data-cd-theme", "data-cd-kontrast"];
|
|
549
|
+
|
|
550
|
+
/** Messages of benign browser reports that are no shell error. */
|
|
551
|
+
const BENIGN_ERRORS = /ResizeObserver loop/;
|
|
552
|
+
|
|
553
|
+
/** Nothing of the shell remains after `destroy` (API.md, `destroy()`). */
|
|
554
|
+
export function leftoverProblems(facts: LeftoverFacts, host: ShellHost): string[] {
|
|
555
|
+
const problems: string[] = [];
|
|
556
|
+
for (const element of facts.classedElements) problems.push(`${element} is left after destroy`);
|
|
557
|
+
const ownDocument = host !== "embedded";
|
|
558
|
+
for (const name of facts.documentClasses) {
|
|
559
|
+
if (ownDocument && name === "cd-shell-document") continue;
|
|
560
|
+
problems.push(`<html> keeps the class ${name} after destroy`);
|
|
561
|
+
}
|
|
562
|
+
for (const element of facts.chromeElements) problems.push(`${element} is left after destroy`);
|
|
563
|
+
for (const name of facts.rootClasses) problems.push(`the root keeps the class ${name} after destroy`);
|
|
564
|
+
const written = ownDocument
|
|
565
|
+
? SHELL_ROOT_ATTRIBUTES
|
|
566
|
+
: [...SHELL_ROOT_ATTRIBUTES, ...EMBEDDED_ROOT_ATTRIBUTES];
|
|
567
|
+
for (const name of facts.rootAttributes) {
|
|
568
|
+
if (written.includes(name)) problems.push(`the root keeps ${name} after destroy`);
|
|
569
|
+
}
|
|
570
|
+
for (const error of facts.errors) {
|
|
571
|
+
if (!BENIGN_ERRORS.test(error)) problems.push(`the page reported an error after destroy: ${error}`);
|
|
572
|
+
}
|
|
573
|
+
return problems;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** After `destroy` no listener of the shell may act on a chrome key any more. */
|
|
577
|
+
export function afterDestroyKeyProblem(press: string, recorded: readonly RecordedKey[]): string | undefined {
|
|
578
|
+
if (recorded.length === 0)
|
|
579
|
+
return `${press} after destroy never reached window: a shell listener still stops it`;
|
|
580
|
+
if (recorded.some((key) => key.defaultPrevented)) {
|
|
581
|
+
return `${press} after destroy arrived prevented: a shell listener is still installed`;
|
|
582
|
+
}
|
|
583
|
+
return undefined;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** The islands a harness may list as `absent`, as far as `geometry` and `aria` see them. */
|
|
587
|
+
export type AbsentIsland = Extract<ShowHook, "legend" | "inspector" | "problems" | "status">;
|
|
588
|
+
|
|
589
|
+
const ABSENT_ISLANDS: readonly AbsentIsland[] = ["legend", "inspector", "problems", "status"];
|
|
590
|
+
|
|
591
|
+
/** The islands of `absent` (its state `invalid` has no island). */
|
|
592
|
+
export function absentIslands(absent: readonly ShowHook[] | undefined): AbsentIsland[] {
|
|
593
|
+
return ABSENT_ISLANDS.filter((island) => absent?.includes(island));
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** An island the harness declares absent must never show. */
|
|
597
|
+
export function absentIslandProblems(
|
|
598
|
+
visible: ReadonlySet<string>,
|
|
599
|
+
absent: readonly ShowHook[] | undefined,
|
|
600
|
+
): string[] {
|
|
601
|
+
return absentIslands(absent)
|
|
602
|
+
.filter((island) => visible.has(island))
|
|
603
|
+
.map((island) => `${island}: visible, but harness.absent says the modeler has no ${island}`);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** A hook the harness both gives and lists as absent is a contradiction in the harness itself. */
|
|
607
|
+
export function harnessHookProblems<P extends PageLike>(
|
|
608
|
+
harness: ShellConformanceHarness<P>,
|
|
609
|
+
hooks: readonly ShowHook[],
|
|
610
|
+
): string[] {
|
|
611
|
+
return hooks
|
|
612
|
+
.filter((hook) => harness.absent?.includes(hook) && harness.show?.[hook])
|
|
613
|
+
.map((hook) => `harness: absent lists "${hook}", but show.${hook} is given`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Guide §10.1: the surround of the canvas is a container painted in `--cd-ui-grund`, in both modes. */
|
|
617
|
+
export function surroundProblems(facts: SurroundFacts): string[] {
|
|
618
|
+
if (!facts.container) return ["canvas surround: no .djs-container in the root to check"];
|
|
619
|
+
if (!facts.painted) {
|
|
620
|
+
return [
|
|
621
|
+
"canvas surround: nothing from .djs-container out to the root paints a background colour; the surround is var(--cd-ui-grund) (guide §10.1)",
|
|
622
|
+
];
|
|
623
|
+
}
|
|
624
|
+
if (facts.painted.color === facts.grund) return [];
|
|
625
|
+
return [
|
|
626
|
+
`canvas surround: ${facts.painted.description} paints ${facts.painted.color}, but --cd-ui-grund resolves to ${facts.grund}; the surround follows the mode (guide §10.1)`,
|
|
627
|
+
];
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Lasso, Hand and the other tools of the `tools` group; every other entry is a notation preview. */
|
|
631
|
+
export function isToolEntry(entry: Pick<PaletteEntryState, "action" | "group">): boolean {
|
|
632
|
+
return entry.group === "tools" || entry.action.endsWith("-tool");
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function paletteEntryState(entry: PaletteColorEntry): string {
|
|
636
|
+
if (entry.disabled) return "aria-disabled";
|
|
637
|
+
if (entry.hovered) return entry.pressed ? "pressed and hovered" : "hovered";
|
|
638
|
+
return entry.pressed ? "pressed" : "at rest";
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Guide §5.5 and §13.5: a tool entry and its icon (`currentColor`) show `--cd-ui-text` at rest and
|
|
643
|
+
* while pressed, `--cd-ui-interaktion` while hovered and `--cd-ui-text-leise` while aria-disabled.
|
|
644
|
+
* Notation previews keep their own colours and are exempt.
|
|
645
|
+
*/
|
|
646
|
+
export function paletteColorProblems(facts: PaletteColorFacts): string[] {
|
|
647
|
+
const problems: string[] = [];
|
|
648
|
+
for (const entry of facts.entries) {
|
|
649
|
+
if (!isToolEntry(entry)) continue;
|
|
650
|
+
const [role, value] = entry.disabled
|
|
651
|
+
? (["--cd-ui-text-leise", facts.roles.textLeise] as const)
|
|
652
|
+
: entry.hovered
|
|
653
|
+
? (["--cd-ui-interaktion", facts.roles.interaktion] as const)
|
|
654
|
+
: (["--cd-ui-text", facts.roles.text] as const);
|
|
655
|
+
const state = paletteEntryState(entry);
|
|
656
|
+
if (entry.color !== value) {
|
|
657
|
+
problems.push(
|
|
658
|
+
`palette: tool "${entry.action}" ${state} has color ${entry.color}, expected ${role} (${value}) (guide §5.5)`,
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
if (entry.icon && entry.icon.color !== value) {
|
|
662
|
+
const icon = entry.icon.kind === "svg" ? "<svg> icon" : "::before icon";
|
|
663
|
+
problems.push(
|
|
664
|
+
`palette: the ${icon} of tool "${entry.action}" ${state} has color ${entry.icon.color}, expected ${role} (${value}); a renderer rule overrides the skin (guide §5.5)`,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return problems;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
export interface CreateFacts {
|
|
672
|
+
/** `data-action` of the entry the kit pressed Enter on. */
|
|
673
|
+
entry: string;
|
|
674
|
+
before: readonly ShapeFacts[];
|
|
675
|
+
after: readonly ShapeFacts[];
|
|
676
|
+
canvas: CanvasStateFacts;
|
|
677
|
+
/** The free rectangle when Enter was pressed (guide §5.9). */
|
|
678
|
+
free: Rectangle;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const round = (value: number): number => Math.round(value * 10) / 10;
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Guide §5.5: Enter on a creating entry places exactly one element in the centre of the free
|
|
685
|
+
* rectangle, selects it and puts the focus on the canvas (its `<svg>` or the label editor).
|
|
686
|
+
*/
|
|
687
|
+
export function createProblems(facts: CreateFacts): string[] {
|
|
688
|
+
const known = new Set(facts.before.map((shape) => shape.id));
|
|
689
|
+
const added = facts.after.filter((shape) => !known.has(shape.id));
|
|
690
|
+
const pressed = `Enter on palette entry "${facts.entry}"`;
|
|
691
|
+
if (added.length === 0) {
|
|
692
|
+
return [
|
|
693
|
+
`create: ${pressed} added no element; Enter on a creating entry places it in the centre of the free rectangle (guide §5.5)`,
|
|
694
|
+
];
|
|
695
|
+
}
|
|
696
|
+
if (added.length > 1) {
|
|
697
|
+
return [
|
|
698
|
+
`create: ${pressed} added ${added.length} elements (${added.map((shape) => shape.id).join(", ")}), expected exactly one`,
|
|
699
|
+
];
|
|
700
|
+
}
|
|
701
|
+
const [shape] = added as [ShapeFacts];
|
|
702
|
+
const problems: string[] = [];
|
|
703
|
+
if (!shape.selected) problems.push(`create: the new element "${shape.id}" is not selected (guide §5.5)`);
|
|
704
|
+
if (!facts.canvas.focusInCanvas) {
|
|
705
|
+
problems.push(
|
|
706
|
+
`create: after ${pressed} the focus is on ${facts.canvas.focused}, expected the canvas (its <svg> or the label editor, guide §5.5)`,
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
const centre = { x: shape.box.left + shape.box.width / 2, y: shape.box.top + shape.box.height / 2 };
|
|
710
|
+
const { free } = facts;
|
|
711
|
+
const tolerance = GEOMETRY_TOLERANCE_PX + 1e-6;
|
|
712
|
+
const inside =
|
|
713
|
+
centre.x >= free.left - tolerance &&
|
|
714
|
+
centre.x <= free.right + tolerance &&
|
|
715
|
+
centre.y >= free.top - tolerance &&
|
|
716
|
+
centre.y <= free.bottom + tolerance;
|
|
717
|
+
if (!inside) {
|
|
718
|
+
problems.push(
|
|
719
|
+
`create: the new element "${shape.id}" is centred at (${round(centre.x)}, ${round(centre.y)})px, outside the free rectangle ${round(free.left)}–${round(free.right)} × ${round(free.top)}–${round(free.bottom)}px (guide §5.5, §5.9)`,
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
return problems;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** The read-only marks once the invalid-file state shows (guide §5.12, §13.5). */
|
|
726
|
+
export function readOnlyStateProblems(facts: ReadOnlyStateFacts): string[] {
|
|
727
|
+
const problems: string[] = [];
|
|
728
|
+
const after = `${INVALID_STATUS_DELAY_MS}ms after show.invalid`;
|
|
729
|
+
if (!facts.readOnly) problems.push(`read-only: the root has no data-readonly ${after}`);
|
|
730
|
+
const status = facts.status;
|
|
731
|
+
if (!status?.visible) {
|
|
732
|
+
problems.push(`read-only: the status box is not visible ${after} (guide §5.12)`);
|
|
733
|
+
} else if (status.attributes.role !== "alert") {
|
|
734
|
+
problems.push(
|
|
735
|
+
`read-only: the status box of an invalid file has role "${status.attributes.role ?? ""}", expected "alert" (guide §13.5)`,
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
return problems;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** The palette entries that stay active on a read-only canvas (guide §13.5), named independently of the shell. */
|
|
742
|
+
export const READ_ONLY_PALETTE_ACTIONS: readonly string[] = ["lasso-tool", "hand-tool"];
|
|
743
|
+
|
|
744
|
+
/** Guide §13.5: every palette entry that changes something is aria-disabled, Lasso and Hand stay active. */
|
|
745
|
+
export function readOnlyPaletteProblems(entries: readonly PaletteEntryState[]): string[] {
|
|
746
|
+
return entries.flatMap((entry) => {
|
|
747
|
+
const staysActive = READ_ONLY_PALETTE_ACTIONS.includes(entry.action);
|
|
748
|
+
if (staysActive && entry.disabled) {
|
|
749
|
+
return [
|
|
750
|
+
`palette: "${entry.action}" is aria-disabled on the read-only canvas; Lasso and Hand stay active (guide §13.5)`,
|
|
751
|
+
];
|
|
752
|
+
}
|
|
753
|
+
if (!staysActive && !entry.disabled) {
|
|
754
|
+
return [`palette: "${entry.action}" is not aria-disabled on the read-only canvas (guide §13.5)`];
|
|
755
|
+
}
|
|
756
|
+
return [];
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** The shell's own Legend toggle in the Model group. */
|
|
761
|
+
const LEGEND_MENU_ID = "legend";
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Guide §13.5 and the product decision on view toggles: on a read-only canvas the Legend toggle,
|
|
765
|
+
* export and help stay usable. The shell renders its `MenuGroups` as Model, Export, Help, empty
|
|
766
|
+
* groups left out, so only the first group can be Model: an entry in a later one that read-only
|
|
767
|
+
* newly disabled is a problem.
|
|
768
|
+
*/
|
|
769
|
+
export function readOnlyMenuProblems(
|
|
770
|
+
before: readonly MenuEntryFacts[],
|
|
771
|
+
after: readonly MenuEntryFacts[],
|
|
772
|
+
): string[] {
|
|
773
|
+
const problems: string[] = [];
|
|
774
|
+
const key = (entry: MenuEntryFacts) => `${entry.menuId ?? ""}\u0000${entry.name}`;
|
|
775
|
+
const enabledBefore = new Set(before.filter((entry) => !entry.disabled).map(key));
|
|
776
|
+
for (const entry of after) {
|
|
777
|
+
if (!entry.disabled) continue;
|
|
778
|
+
if (entry.menuId === LEGEND_MENU_ID && entry.role === "menuitemcheckbox") {
|
|
779
|
+
problems.push(
|
|
780
|
+
"menu: the Legend toggle is aria-disabled on the read-only canvas; a view-only toggle stays usable (guide §13.5)",
|
|
781
|
+
);
|
|
782
|
+
} else if (entry.group !== "model" && enabledBefore.has(key(entry))) {
|
|
783
|
+
problems.push(
|
|
784
|
+
`menu: "${entry.name}" is aria-disabled on the read-only canvas; export and help stay usable (guide §13.5)`,
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return problems;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** What the read-only lock must keep from a shape (guide §13.5): no context pad, move, resize, delete or label editor. */
|
|
792
|
+
export interface LockedShapeFacts {
|
|
793
|
+
/** Context-pad entries visible after clicking the shape. */
|
|
794
|
+
contextPadEntries: readonly string[];
|
|
795
|
+
/** The shape before and after a 40px drag (`undefined`: gone). */
|
|
796
|
+
dragged: { before: ShapeFacts; after: ShapeFacts | undefined };
|
|
797
|
+
/** Text fields inside `.djs-container` after a double-click on the shape. */
|
|
798
|
+
textFields: readonly string[];
|
|
799
|
+
/** Whether the shape is still there after Delete, then after Backspace. */
|
|
800
|
+
kept: { Delete: boolean; Backspace: boolean };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
export function lockedShapeProblems(facts: LockedShapeFacts): string[] {
|
|
804
|
+
const problems: string[] = [];
|
|
805
|
+
const { before, after } = facts.dragged;
|
|
806
|
+
if (facts.contextPadEntries.length > 0) {
|
|
807
|
+
problems.push(
|
|
808
|
+
`read-only: selecting "${before.id}" shows the context pad (${facts.contextPadEntries.join(", ")}) (guide §13.5)`,
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
if (!after) {
|
|
812
|
+
problems.push(`read-only: a 40px drag removed "${before.id}" from the canvas (guide §13.5)`);
|
|
813
|
+
} else if (
|
|
814
|
+
after.transform !== before.transform ||
|
|
815
|
+
Math.abs(after.size.width - before.size.width) > 0.5 ||
|
|
816
|
+
Math.abs(after.size.height - before.size.height) > 0.5
|
|
817
|
+
) {
|
|
818
|
+
problems.push(
|
|
819
|
+
`read-only: a 40px drag changed "${before.id}" (transform "${before.transform}" → "${after.transform}", ${round(before.size.width)} × ${round(before.size.height)} → ${round(after.size.width)} × ${round(after.size.height)}) (guide §13.5)`,
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
if (facts.textFields.length > 0) {
|
|
823
|
+
problems.push(
|
|
824
|
+
`read-only: a double-click on "${before.id}" opens a text field (${facts.textFields.join(", ")}) (guide §13.5)`,
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
for (const key of ["Delete", "Backspace"] as const) {
|
|
828
|
+
if (!facts.kept[key])
|
|
829
|
+
problems.push(`read-only: ${key} on the canvas deleted "${before.id}" (guide §13.5)`);
|
|
830
|
+
}
|
|
831
|
+
return problems;
|
|
832
|
+
}
|