@miragon/modeler-shell 0.1.0 → 0.2.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 +1051 -84
- package/README.md +162 -31
- package/dist/appearance-BBaM_4bD.d.ts +323 -0
- package/dist/appearance-XX3JC3pI.d.cts +323 -0
- package/dist/{chunk-6LQOFACK.js → chunk-66HCSJNY.js} +1247 -31
- package/dist/chunk-66HCSJNY.js.map +1 -0
- package/dist/{chunk-RJRFR6N3.js → chunk-KYDG5DMX.js} +2 -2
- package/dist/{chunk-5JSZ4VGA.js → chunk-OADW3GPA.js} +240 -6
- package/dist/chunk-OADW3GPA.js.map +1 -0
- package/dist/core.cjs +260 -6
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +81 -6
- package/dist/core.d.ts +81 -6
- package/dist/core.js +43 -3
- package/dist/diagram-js.cjs +34 -2
- package/dist/diagram-js.cjs.map +1 -1
- package/dist/diagram-js.d.cts +10 -3
- package/dist/diagram-js.d.ts +10 -3
- package/dist/diagram-js.js +14 -2
- package/dist/diagram-js.js.map +1 -1
- package/dist/index.cjs +1654 -197
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -9
- package/dist/index.d.ts +18 -9
- package/dist/index.js +44 -4
- package/dist/shell.css +692 -3
- package/dist/testing-vscode-stub.js +2 -2
- package/dist/testing.cjs +2849 -550
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +75 -9
- package/dist/testing.d.ts +75 -9
- package/dist/testing.js +2744 -558
- package/dist/testing.js.map +1 -1
- package/dist/toasts-Bp0REqdq.d.cts +142 -0
- package/dist/toasts-Bp0REqdq.d.ts +142 -0
- package/dist/{types-OuFlqZp0.d.cts → types-CnRrINRs.d.cts} +170 -9
- package/dist/{types-DnZn9CPw.d.ts → types-PuDZczzy.d.ts} +170 -9
- package/dist/vscode-extension.js +2 -2
- package/dist/vscode-webview.cjs +1541 -178
- package/dist/vscode-webview.cjs.map +1 -1
- package/dist/vscode-webview.d.cts +6 -4
- package/dist/vscode-webview.d.ts +6 -4
- package/dist/vscode-webview.js +11 -2
- package/dist/vscode-webview.js.map +1 -1
- package/package.json +2 -2
- package/src/core/feedback/appearance.ts +141 -0
- package/src/core/feedback/problems.ts +23 -0
- package/src/core/feedback/toasts.ts +118 -0
- package/src/core/fit.ts +18 -3
- package/src/core/index.ts +43 -0
- package/src/core/keymap.ts +20 -0
- package/src/core/messages.ts +71 -0
- package/src/core/types.ts +24 -2
- package/src/core/web-chrome/bottom-row.ts +37 -0
- package/src/core/web-chrome/drop.ts +78 -0
- package/src/core/web-chrome/top-row.ts +66 -0
- package/src/diagram-js/service.ts +4 -1
- package/src/diagram-js/viewport.ts +19 -0
- package/src/dom/buttons.css +124 -0
- package/src/dom/feedback/appearance.ts +184 -0
- package/src/dom/feedback/feedback.css +435 -0
- package/src/dom/feedback/problems-popover.ts +287 -0
- package/src/dom/feedback/toasts.ts +431 -0
- package/src/dom/index.ts +13 -0
- package/src/dom/keys.ts +8 -0
- package/src/dom/menu.ts +28 -17
- package/src/dom/mount.ts +290 -20
- package/src/dom/problems.ts +5 -4
- package/src/dom/shell-dom.css +7 -4
- package/src/dom/types.ts +188 -6
- package/src/dom/web/drop-zone.ts +181 -0
- package/src/dom/web/empty-state.ts +144 -0
- package/src/dom/web/legal.ts +75 -0
- package/src/dom/web/menu-groups.ts +69 -0
- package/src/dom/web/primary.ts +124 -0
- package/src/dom/web/web.css +386 -0
- package/src/shell.css +3 -0
- package/src/testing/case-tools.ts +164 -0
- package/src/testing/cases.ts +209 -144
- package/src/testing/checks.ts +51 -15
- package/src/testing/facts.ts +56 -2
- package/src/testing/geometry.ts +206 -26
- package/src/testing/harness.ts +56 -7
- package/src/testing/index.ts +4 -0
- package/src/testing/page-tools.ts +24 -1
- package/src/testing/page.ts +10 -0
- package/src/testing/web-cases.ts +913 -0
- package/src/testing/web-checks.ts +1049 -0
- package/src/testing/web-facts.ts +713 -0
- package/src/vscode/webview/mount.ts +13 -1
- package/dist/chunk-5JSZ4VGA.js.map +0 -1
- package/dist/chunk-6LQOFACK.js.map +0 -1
- package/dist/keymap-CObQE6q6.d.cts +0 -151
- package/dist/keymap-DORzHnK-.d.ts +0 -151
- package/dist/types-B6NfkiUG.d.cts +0 -66
- package/dist/types-B6NfkiUG.d.ts +0 -66
- /package/dist/{chunk-RJRFR6N3.js.map → chunk-KYDG5DMX.js.map} +0 -0
|
@@ -0,0 +1,1049 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_TOASTS,
|
|
3
|
+
SHELL,
|
|
4
|
+
decideTopRow,
|
|
5
|
+
defaultMessages,
|
|
6
|
+
legalLineHeight,
|
|
7
|
+
type Appearance,
|
|
8
|
+
type AppearanceChoice,
|
|
9
|
+
type ShellHost,
|
|
10
|
+
type Size,
|
|
11
|
+
type Theme,
|
|
12
|
+
type ToastStatus,
|
|
13
|
+
} from "../core/index.js";
|
|
14
|
+
import type { AriaFacts, MenuEntryFacts } from "./facts.js";
|
|
15
|
+
import { GEOMETRY_TOLERANCE_PX, boxMismatches, type IslandBox } from "./geometry.js";
|
|
16
|
+
import type { ModeChange, RecordedKey } from "./page-tools.js";
|
|
17
|
+
import type {
|
|
18
|
+
DropZoneFacts,
|
|
19
|
+
EmptyStateFacts,
|
|
20
|
+
LegalLineFacts,
|
|
21
|
+
LiveRegionFacts,
|
|
22
|
+
PopoverFacts,
|
|
23
|
+
ToastFacts,
|
|
24
|
+
TopRowFacts,
|
|
25
|
+
WebModeFacts,
|
|
26
|
+
} from "./web-facts.js";
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
* The judgements of the web column (0.2), as pure functions of what the probes in web-facts.ts saw.
|
|
30
|
+
* Each returns readable problems (empty = conforming).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const round = (value: number): number => Math.round(value * 10) / 10;
|
|
34
|
+
|
|
35
|
+
const normalize = (text: string): string => text.replace(/\s+/g, " ").trim();
|
|
36
|
+
|
|
37
|
+
/** The Miragon legal pages, from the corporate-identity docs-site kit (`miragon/legal.ts`). */
|
|
38
|
+
export const MIRAGON_HOME = "https://www.miragon.io/";
|
|
39
|
+
export const MIRAGON_IMPRESSUM = "https://www.miragon.io/impressum/";
|
|
40
|
+
export const MIRAGON_DATENSCHUTZ = "https://www.miragon.io/datenschutz/";
|
|
41
|
+
|
|
42
|
+
/** Guide §2: these two stay German in every tool language. */
|
|
43
|
+
const GERMAN_LEGAL_LINKS = ["Impressum", "Datenschutz"] as const;
|
|
44
|
+
|
|
45
|
+
/** Whether the tool speaks English, so the kit may compare names with the shell's default messages. */
|
|
46
|
+
export function speaksEnglish(lang: string): boolean {
|
|
47
|
+
return /^en(-|$)/i.test(lang.trim());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---- aria (extended) ----
|
|
51
|
+
|
|
52
|
+
/** The primary action's role and name (guide §5.7, §5.15, WCAG 2.5.3). */
|
|
53
|
+
export function primaryAriaProblems(facts: AriaFacts, host: ShellHost): string[] {
|
|
54
|
+
const primary = facts.primary;
|
|
55
|
+
if (!primary || host !== "web") return [];
|
|
56
|
+
const problems: string[] = [];
|
|
57
|
+
if (primary.tag !== "button") problems.push(`primary: a <${primary.tag}>, expected a <button>`);
|
|
58
|
+
const label = normalize(primary.attributes["aria-label"] ?? "");
|
|
59
|
+
if (!label) {
|
|
60
|
+
problems.push(
|
|
61
|
+
"primary: no aria-label; it names the action also while the label is collapsed (guide §5.15)",
|
|
62
|
+
);
|
|
63
|
+
} else if (!facts.primaryCollapsed && primary.text && !label.includes(primary.text)) {
|
|
64
|
+
problems.push(
|
|
65
|
+
`primary: the aria-label "${label}" does not contain the visible label "${primary.text}" (WCAG 2.5.3)`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (primary.disabled) {
|
|
69
|
+
problems.push(
|
|
70
|
+
"primary: natively disabled; a disabled primary action keeps the focus with aria-disabled (guide §8)",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return problems;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The legal line's landmark and languages (guide §2, §5.10). */
|
|
77
|
+
export function legalAriaProblems(facts: AriaFacts, host: ShellHost): string[] {
|
|
78
|
+
if (host !== "web") return [];
|
|
79
|
+
const legal = facts.legal;
|
|
80
|
+
if (!legal) return ['legal: no [data-chrome="legal"] in the web app (guide §5.10)'];
|
|
81
|
+
const problems: string[] = [];
|
|
82
|
+
if (legal.tag !== "nav") problems.push(`legal: the legal line is a <${legal.tag}>, expected a <nav>`);
|
|
83
|
+
if (!normalize(legal.attributes["aria-label"] ?? "")) problems.push("legal: the <nav> has no aria-label");
|
|
84
|
+
for (const text of GERMAN_LEGAL_LINKS) {
|
|
85
|
+
const link = facts.legalLinks.find((candidate) => candidate.text === text);
|
|
86
|
+
if (!link) {
|
|
87
|
+
problems.push(`legal: no "${text}" link (guide §5.10)`);
|
|
88
|
+
} else if (link.lang !== "de") {
|
|
89
|
+
problems.push(
|
|
90
|
+
`legal: "${text}" has ${link.lang === null ? "no lang" : `lang="${link.lang}"`}, expected lang="de" (guide §2: Impressum and Datenschutz stay German)`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const link of facts.legalLinks) {
|
|
95
|
+
if (link.target !== "_blank") continue;
|
|
96
|
+
const rel = (link.rel ?? "").split(/\s+/);
|
|
97
|
+
if (!rel.includes("noopener") || !rel.includes("noreferrer")) {
|
|
98
|
+
problems.push(`legal: the link "${link.text}" opens a new tab without rel="noopener noreferrer"`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return problems;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The pill as the trigger of the problems list (guide §5.10): web, and embedded when it opens one. */
|
|
105
|
+
export function pillPopupAriaProblems(facts: AriaFacts, host: ShellHost): string[] {
|
|
106
|
+
const pill = facts.problems;
|
|
107
|
+
if (!pill || host === "ide") return [];
|
|
108
|
+
const haspopup = pill.attributes["aria-haspopup"];
|
|
109
|
+
if (host === "embedded" && haspopup === undefined) return [];
|
|
110
|
+
const problems: string[] = [];
|
|
111
|
+
if (haspopup !== "dialog") {
|
|
112
|
+
problems.push(
|
|
113
|
+
`problems: the pill has aria-haspopup "${haspopup ?? ""}", expected "dialog" (it opens the problems list)`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (pill.attributes["aria-expanded"] !== "false") {
|
|
117
|
+
problems.push(
|
|
118
|
+
`problems: the pill has aria-expanded "${pill.attributes["aria-expanded"] ?? ""}" while the list is closed, expected "false"`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const controls = pill.attributes["aria-controls"];
|
|
122
|
+
if (!controls) {
|
|
123
|
+
problems.push("problems: the pill has no aria-controls");
|
|
124
|
+
} else if (!facts.problemsControlled) {
|
|
125
|
+
problems.push(`problems: aria-controls="${controls}" names no element`);
|
|
126
|
+
} else {
|
|
127
|
+
const role = facts.problemsControlled.attributes.role;
|
|
128
|
+
if (role !== "dialog") {
|
|
129
|
+
problems.push(
|
|
130
|
+
`problems: aria-controls names a ${facts.problemsControlled.description} with role "${role ?? ""}", expected "dialog"`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (facts.problemsControlled.attributes["aria-modal"] === "true") {
|
|
134
|
+
problems.push(
|
|
135
|
+
'problems: the problems list is aria-modal="true"; it is a non-modal popover (guide §5.10)',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return problems;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The fixed order of the menu groups (guide §5.4). */
|
|
143
|
+
export const MENU_GROUP_ORDER = ["file", "model", "export", "appearance", "help"] as const;
|
|
144
|
+
|
|
145
|
+
/** The File group's entries in their order, and whether the guide gives each an icon (§5.4, §5.14). */
|
|
146
|
+
export const FILE_ENTRIES: ReadonlyArray<{ id: string; icon: boolean }> = [
|
|
147
|
+
{ id: "new-diagram", icon: true },
|
|
148
|
+
{ id: "open", icon: true },
|
|
149
|
+
{ id: "show-example", icon: false },
|
|
150
|
+
{ id: "import", icon: true },
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
const APPEARANCE_VALUES: readonly AppearanceChoice[] = ["system", "light", "dark"];
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The web app's File and Appearance groups (guide §5.4, §13.3): groups in the fixed order, the File
|
|
157
|
+
* group's ids, order and icons, the Appearance radio group of a dark-capable modeler with its three
|
|
158
|
+
* options and exactly the expected one checked; neither group outside the web app.
|
|
159
|
+
*/
|
|
160
|
+
export function menuGroupProblems(
|
|
161
|
+
entries: readonly MenuEntryFacts[],
|
|
162
|
+
host: ShellHost,
|
|
163
|
+
themes: readonly Theme[],
|
|
164
|
+
choice: AppearanceChoice = "system",
|
|
165
|
+
): string[] {
|
|
166
|
+
const problems: string[] = [];
|
|
167
|
+
const file = entries.filter((entry) => entry.group === "file");
|
|
168
|
+
const appearance = entries.filter((entry) => entry.group === "appearance");
|
|
169
|
+
if (host !== "web") {
|
|
170
|
+
const guide =
|
|
171
|
+
host === "ide"
|
|
172
|
+
? "its file actions are native (guide §13.5)"
|
|
173
|
+
: "the host owns file and mode (guide §5.2)";
|
|
174
|
+
if (file.length > 0) problems.push(`menu: the ${host} host shows a File group; ${guide}`);
|
|
175
|
+
if (appearance.length > 0) problems.push(`menu: the ${host} host shows an Appearance group; ${guide}`);
|
|
176
|
+
return problems;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const sequence: string[] = [];
|
|
180
|
+
for (const entry of entries) {
|
|
181
|
+
if (entry.group && sequence.at(-1) !== entry.group) sequence.push(entry.group);
|
|
182
|
+
}
|
|
183
|
+
const rank = (group: string) => MENU_GROUP_ORDER.indexOf(group as (typeof MENU_GROUP_ORDER)[number]);
|
|
184
|
+
const ordered = sequence.every((group, index) => index === 0 || rank(sequence[index - 1]!) < rank(group));
|
|
185
|
+
if (!ordered) {
|
|
186
|
+
problems.push(
|
|
187
|
+
`menu: the groups stand in the order ${sequence.join(", ")}; expected File, Model, Export, Appearance, Help (guide §5.4)`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const known = FILE_ENTRIES.map((entry) => entry.id);
|
|
192
|
+
for (const entry of file) {
|
|
193
|
+
const spec = FILE_ENTRIES.find((candidate) => candidate.id === entry.menuId);
|
|
194
|
+
if (!spec) {
|
|
195
|
+
problems.push(
|
|
196
|
+
`menu: File entry "${entry.name}" has data-menu-id "${entry.menuId ?? ""}"; the shell builds the File group from ${known.join(", ")}`,
|
|
197
|
+
);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (entry.role !== "menuitem")
|
|
201
|
+
problems.push(`menu: File entry "${entry.name}" is a ${entry.role}, expected a menuitem`);
|
|
202
|
+
if (spec.icon && !entry.icon) problems.push(`menu: File entry "${entry.name}" has no icon (guide §5.4)`);
|
|
203
|
+
if (!spec.icon && entry.icon) {
|
|
204
|
+
problems.push(
|
|
205
|
+
`menu: File entry "${entry.name}" carries an icon; the guide gives ${entry.menuId} none (guide §5.14)`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const fileIds = file.map((entry) => entry.menuId ?? "");
|
|
210
|
+
const expectedIds = known.filter((id) => fileIds.includes(id));
|
|
211
|
+
if (fileIds.join() !== expectedIds.join() && fileIds.every((id) => known.includes(id))) {
|
|
212
|
+
problems.push(
|
|
213
|
+
`menu: the File group reads ${fileIds.join(", ")}; expected the order ${expectedIds.join(", ")} (guide §5.4)`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (themes.includes("dark")) {
|
|
218
|
+
if (appearance.length === 0) {
|
|
219
|
+
problems.push("menu: a dark-capable modeler shows no Appearance group (guide §5.4, §13.3)");
|
|
220
|
+
} else {
|
|
221
|
+
const values = appearance.map((entry) => entry.value ?? "");
|
|
222
|
+
if (values.join() !== APPEARANCE_VALUES.join()) {
|
|
223
|
+
problems.push(
|
|
224
|
+
`menu: the Appearance options are ${values.join(", ")}; expected system, light, dark (guide §13.3)`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
for (const entry of appearance) {
|
|
228
|
+
if (entry.role !== "menuitemradio" || entry.menuId !== "appearance") {
|
|
229
|
+
problems.push(
|
|
230
|
+
`menu: Appearance option "${entry.name}" is a ${entry.role} in "${entry.menuId ?? ""}"; expected a menuitemradio of the radio group "appearance"`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
problems.push(...appearanceChoiceProblems(entries, choice));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return problems;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The Appearance radio group checks exactly `choice` (`aria-checked`, guide §12). */
|
|
241
|
+
export function appearanceChoiceProblems(
|
|
242
|
+
entries: readonly MenuEntryFacts[],
|
|
243
|
+
choice: AppearanceChoice,
|
|
244
|
+
): string[] {
|
|
245
|
+
const checked = entries
|
|
246
|
+
.filter((entry) => entry.group === "appearance" && entry.checked === "true")
|
|
247
|
+
.map((entry) => entry.value ?? "");
|
|
248
|
+
return checked.length === 1 && checked[0] === choice
|
|
249
|
+
? []
|
|
250
|
+
: [
|
|
251
|
+
`menu: the Appearance group checks ${checked.length > 0 ? checked.join(", ") : "nothing"}; expected "${choice}" (aria-checked, guide §12)`,
|
|
252
|
+
];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Guide §13.3: a light-only modeler has no Appearance group. */
|
|
256
|
+
export function lightOnlyMenuProblems(
|
|
257
|
+
entries: readonly MenuEntryFacts[],
|
|
258
|
+
themes: readonly Theme[],
|
|
259
|
+
): string[] {
|
|
260
|
+
if (themes.includes("dark")) return [];
|
|
261
|
+
return entries.some((entry) => entry.group === "appearance" || entry.menuId === "appearance")
|
|
262
|
+
? ["menu: a light-only modeler shows the Appearance group; it comes with dark (guide §13.3)"]
|
|
263
|
+
: [];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---- webChrome ----
|
|
267
|
+
|
|
268
|
+
/** The labelled width of the primary action: its widest measurement while it showed the label. */
|
|
269
|
+
export function labelledWidth(samples: readonly TopRowFacts[]): number | undefined {
|
|
270
|
+
const widths = samples.flatMap((sample) =>
|
|
271
|
+
sample.primary && !sample.primary.collapsed && sample.primary.label ? [sample.primary.box.width] : [],
|
|
272
|
+
);
|
|
273
|
+
return widths.length > 0 ? Math.max(...widths) : undefined;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Guide §5.15 at every measured width: the primary action loses its label before the palette
|
|
278
|
+
* moves to the second row (`decideTopRow` with the measured widths); collapsed it is 40 × 40, keeps
|
|
279
|
+
* its `aria-label` and has no `aria-describedby`. A width where a 1px difference flips the
|
|
280
|
+
* decision is left out.
|
|
281
|
+
*/
|
|
282
|
+
export function topRowProblems(samples: readonly TopRowFacts[], withLabel: number, name: string): string[] {
|
|
283
|
+
const problems: string[] = [];
|
|
284
|
+
for (const sample of samples) {
|
|
285
|
+
const { container, paletteWidth, menuRight, primary } = sample;
|
|
286
|
+
if (!primary || paletteWidth === null || menuRight === null) continue;
|
|
287
|
+
const decide = (palette: number, labelled: number) =>
|
|
288
|
+
decideTopRow({
|
|
289
|
+
containerWidth: container.width,
|
|
290
|
+
paletteWidth: palette,
|
|
291
|
+
menuRight,
|
|
292
|
+
primary: { withLabel: labelled, iconOnly: SHELL.row },
|
|
293
|
+
});
|
|
294
|
+
const expected = decide(paletteWidth, withLabel);
|
|
295
|
+
const low = decide(paletteWidth - 1, withLabel - 1);
|
|
296
|
+
const high = decide(paletteWidth + 1, withLabel + 1);
|
|
297
|
+
const at = `[${round(container.width)}px wide]`;
|
|
298
|
+
if (
|
|
299
|
+
low.paletteRow === high.paletteRow &&
|
|
300
|
+
low.primaryLabel === high.primaryLabel &&
|
|
301
|
+
expected.paletteRow === low.paletteRow &&
|
|
302
|
+
expected.primaryLabel === low.primaryLabel
|
|
303
|
+
) {
|
|
304
|
+
const collapsed = expected.primaryLabel === "collapsed";
|
|
305
|
+
if (collapsed && !primary.collapsed) {
|
|
306
|
+
problems.push(
|
|
307
|
+
expected.paletteRow === "top"
|
|
308
|
+
? `${at} primary: shows its label, but the ${round(paletteWidth)}px palette fits the top row only beside the icon; the label collapses first (guide §5.15)`
|
|
309
|
+
: `${at} primary: shows its label, but it no longer fits next to the menu trigger (data-primary="collapsed", guide §5.15)`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
if (!collapsed && primary.collapsed) {
|
|
313
|
+
problems.push(`${at} primary: collapsed although its ${round(withLabel)}px label fits (guide §5.15)`);
|
|
314
|
+
}
|
|
315
|
+
if (expected.paletteRow !== sample.paletteRow) {
|
|
316
|
+
problems.push(
|
|
317
|
+
expected.paletteRow === "top"
|
|
318
|
+
? `${at} palette: in the second row although it fits the top row beside the collapsed primary action; the label collapses first (guide §5.15)`
|
|
319
|
+
: `${at} palette: in the top row although it keeps less than 16px to the primary action even icon only (guide §5.15)`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (primary.collapsed) {
|
|
324
|
+
if (Math.abs(primary.box.width - SHELL.row) > GEOMETRY_TOLERANCE_PX + 1e-6) {
|
|
325
|
+
problems.push(
|
|
326
|
+
`${at} primary: ${round(primary.box.width)}px wide while collapsed, expected ${SHELL.row}px`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
if (primary.label)
|
|
330
|
+
problems.push(`${at} primary: collapsed, but its label "${primary.label}" still shows`);
|
|
331
|
+
if (primary.ariaLabel !== name) {
|
|
332
|
+
problems.push(
|
|
333
|
+
`${at} primary: the aria-label is "${primary.ariaLabel ?? ""}" while collapsed; it keeps "${name}" (guide §5.15)`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
} else if (!primary.label) {
|
|
337
|
+
problems.push(`${at} primary: shows no label although the root lacks data-primary="collapsed"`);
|
|
338
|
+
}
|
|
339
|
+
if (primary.describedBy) {
|
|
340
|
+
problems.push(
|
|
341
|
+
`${at} primary: aria-describedby="${primary.describedBy}"; its tooltip repeats the name (API.md, Primary action)`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return problems;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** The chrome tooltip of the primary action: only while collapsed, with its label (guide §5.15). */
|
|
349
|
+
export function primaryTooltipProblems(
|
|
350
|
+
collapsed: boolean,
|
|
351
|
+
tooltip: string | null,
|
|
352
|
+
name: string,
|
|
353
|
+
width: number,
|
|
354
|
+
): string[] {
|
|
355
|
+
const at = `[${round(width)}px wide]`;
|
|
356
|
+
if (collapsed && tooltip === null) {
|
|
357
|
+
return [`${at} primary: no chrome tooltip while collapsed; it shows the label "${name}" (guide §5.15)`];
|
|
358
|
+
}
|
|
359
|
+
if (collapsed && tooltip !== null && !normalize(tooltip).startsWith(name)) {
|
|
360
|
+
return [`${at} primary: the tooltip while collapsed reads "${normalize(tooltip)}", expected "${name}"`];
|
|
361
|
+
}
|
|
362
|
+
if (!collapsed && tooltip !== null) {
|
|
363
|
+
return [
|
|
364
|
+
`${at} primary: a tooltip "${normalize(tooltip)}" while the label shows; only the collapsed action has one`,
|
|
365
|
+
];
|
|
366
|
+
}
|
|
367
|
+
return [];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** A key on the primary action: a native button turns Enter into one click (guide §5.7). */
|
|
371
|
+
export function primaryActivationProblems(clicks: { pointer: number; enter: number }): string[] {
|
|
372
|
+
const problems: string[] = [];
|
|
373
|
+
if (clicks.pointer !== 1)
|
|
374
|
+
problems.push(`primary: a pointer click reached it ${clicks.pointer} times, expected once`);
|
|
375
|
+
if (clicks.enter !== 1) {
|
|
376
|
+
problems.push(
|
|
377
|
+
`primary: Enter on the focused primary action clicked it ${clicks.enter} times, expected once (keyboard activation)`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
return problems;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** The legal line's content (guide §5.10, docs-site kit `miragon/legal.ts`). */
|
|
384
|
+
export function legalContentProblems(links: AriaFacts["legalLinks"]): string[] {
|
|
385
|
+
const problems: string[] = [];
|
|
386
|
+
const [copyright, impressum, datenschutz] = links;
|
|
387
|
+
if (!copyright || !/^©/.test(copyright.text) || !copyright.text.includes("Miragon GmbH")) {
|
|
388
|
+
problems.push(
|
|
389
|
+
`legal: the line starts with "${copyright?.text ?? ""}", expected the copyright "© <year> Miragon GmbH" (guide §5.10)`,
|
|
390
|
+
);
|
|
391
|
+
} else if (copyright.href !== MIRAGON_HOME) {
|
|
392
|
+
problems.push(`legal: the copyright links "${copyright.href}", expected ${MIRAGON_HOME}`);
|
|
393
|
+
}
|
|
394
|
+
const expected: Array<[typeof impressum, string, string]> = [
|
|
395
|
+
[impressum, "Impressum", MIRAGON_IMPRESSUM],
|
|
396
|
+
[datenschutz, "Datenschutz", MIRAGON_DATENSCHUTZ],
|
|
397
|
+
];
|
|
398
|
+
for (const [link, text, href] of expected) {
|
|
399
|
+
if (link?.text !== text) {
|
|
400
|
+
problems.push(
|
|
401
|
+
`legal: the links read ${links.map((entry) => `"${entry.text}"`).join(", ")}; expected the copyright, "Impressum", "Datenschutz", then the modeler's links (guide §5.10)`,
|
|
402
|
+
);
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
if (link.href !== href) problems.push(`legal: "${text}" links "${link.href}", expected ${href}`);
|
|
406
|
+
}
|
|
407
|
+
return [...new Set(problems)];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Guide §5.10, WCAG 1.4.10: in the narrowest container every legal link lies whole inside the legal
|
|
412
|
+
* line and the container, so Impressum and Datenschutz are never cut off or scrolled away.
|
|
413
|
+
*/
|
|
414
|
+
export function legalReachProblems(facts: LegalLineFacts): string[] {
|
|
415
|
+
const legal = facts.legal;
|
|
416
|
+
if (!legal) return [];
|
|
417
|
+
const width = round(facts.container.width);
|
|
418
|
+
const problems: string[] = [];
|
|
419
|
+
for (const link of facts.links) {
|
|
420
|
+
const box = link.box;
|
|
421
|
+
const inside =
|
|
422
|
+
box.left >= Math.max(0, legal.left) - GEOMETRY_TOLERANCE_PX &&
|
|
423
|
+
box.left + box.width <=
|
|
424
|
+
Math.min(legal.left + legal.width, facts.container.width) + GEOMETRY_TOLERANCE_PX &&
|
|
425
|
+
box.top >= legal.top - GEOMETRY_TOLERANCE_PX &&
|
|
426
|
+
box.top + box.height <= legal.top + legal.height + GEOMETRY_TOLERANCE_PX;
|
|
427
|
+
if (!inside) {
|
|
428
|
+
problems.push(
|
|
429
|
+
`legal: [${width}px wide] "${link.text}" is cut off or scrolled out of the legal line; it wraps instead (guide §5.10, WCAG 1.4.10)`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return problems;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* The zoom panel's bottom in the web app: 12px, 40px while `data-zoom-row="raised"` (on a legal line
|
|
438
|
+
* that wraps, 8px above its measured `legalHeight`).
|
|
439
|
+
*/
|
|
440
|
+
export function zoomRowBottomProblems(
|
|
441
|
+
zoom: IslandBox | undefined,
|
|
442
|
+
raised: boolean,
|
|
443
|
+
container: Size,
|
|
444
|
+
legalHeight?: number,
|
|
445
|
+
): string[] {
|
|
446
|
+
if (!zoom) return [];
|
|
447
|
+
const bottom = raised ? SHELL.edge + legalLineHeight(legalHeight) + SHELL.gap : SHELL.edge;
|
|
448
|
+
return boxMismatches("zoom", zoom, { bottom }, container).map(
|
|
449
|
+
(problem) => `[${round(container.width)}px wide${raised ? ", raised" : ""}] ${problem}`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ---- emptyState ----
|
|
454
|
+
|
|
455
|
+
/** The start screen of guide §8 as `show.emptyState` leaves it. */
|
|
456
|
+
export function emptyStateProblems(facts: EmptyStateFacts, container: Size, lang: string): string[] {
|
|
457
|
+
const problems: string[] = [];
|
|
458
|
+
if (!facts.shown)
|
|
459
|
+
problems.push('empty-state: the root lacks data-empty-state="shown" after show.emptyState');
|
|
460
|
+
const card = facts.card;
|
|
461
|
+
if (!card?.visible) return [...problems, "empty-state: the card is not visible after show.emptyState"];
|
|
462
|
+
problems.push(
|
|
463
|
+
...boxMismatches(
|
|
464
|
+
"empty-state",
|
|
465
|
+
card.box,
|
|
466
|
+
{
|
|
467
|
+
centerX: container.width / 2,
|
|
468
|
+
centerY: container.height / 2,
|
|
469
|
+
width: Math.min(400, container.width - 2 * SHELL.edge),
|
|
470
|
+
},
|
|
471
|
+
container,
|
|
472
|
+
),
|
|
473
|
+
);
|
|
474
|
+
if (!card.labelledBy) problems.push("empty-state: the card is not labelled by its title (aria-labelledby)");
|
|
475
|
+
if (!facts.komet) {
|
|
476
|
+
problems.push("empty-state: no Komet <img> on the card (guide §2, §8)");
|
|
477
|
+
} else {
|
|
478
|
+
if (facts.komet.alt !== "") {
|
|
479
|
+
problems.push(
|
|
480
|
+
`empty-state: the Komet has ${facts.komet.alt === null ? "no alt" : `alt="${facts.komet.alt}"`}; it is decorative, alt="" (guide §8)`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
if (Math.abs(facts.komet.width - 64) > 1 || Math.abs(facts.komet.height - 64) > 1) {
|
|
484
|
+
problems.push(
|
|
485
|
+
`empty-state: the Komet is ${round(facts.komet.width)} × ${round(facts.komet.height)}px, expected 64 × 64 (guide §8)`,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (facts.heading?.tag !== "h1") {
|
|
490
|
+
problems.push(
|
|
491
|
+
`empty-state: the title is ${facts.heading ? `a <${facts.heading.tag}>` : "missing"}, expected an <h1>`,
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
const [primary, ghost, ...rest] = facts.buttons;
|
|
495
|
+
if (!primary) {
|
|
496
|
+
problems.push('empty-state: no "New diagram" button on the card (guide §8)');
|
|
497
|
+
} else {
|
|
498
|
+
if (primary.variant !== "primary") {
|
|
499
|
+
problems.push(
|
|
500
|
+
`empty-state: the first button "${primary.name}" is ${primary.variant ?? "no"} variant, expected the blue primary "New diagram" first (guide §8)`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
if (ghost && ghost.variant !== "ghost") {
|
|
504
|
+
problems.push(
|
|
505
|
+
`empty-state: the second button "${ghost.name}" is ${ghost.variant ?? "no"} variant, expected the ghost "Show example" (guide §8)`,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
if (rest.length > 0)
|
|
509
|
+
problems.push(
|
|
510
|
+
`empty-state: ${facts.buttons.length} buttons on the card; exactly two actions (guide §8)`,
|
|
511
|
+
);
|
|
512
|
+
for (const button of [primary, ghost]) {
|
|
513
|
+
if (button && Math.abs(button.height - SHELL.entry) > 1) {
|
|
514
|
+
problems.push(
|
|
515
|
+
`empty-state: "${button.name}" is ${round(button.height)}px high, expected ${SHELL.entry}px (guide §8)`,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (speaksEnglish(lang)) {
|
|
520
|
+
if (primary.name !== defaultMessages.newDiagram) {
|
|
521
|
+
problems.push(
|
|
522
|
+
`empty-state: the primary button reads "${primary.name}", expected "${defaultMessages.newDiagram}" (guide §2)`,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
if (ghost && ghost.name !== defaultMessages.showExample) {
|
|
526
|
+
problems.push(
|
|
527
|
+
`empty-state: the ghost button reads "${ghost.name}", expected "${defaultMessages.showExample}" (guide §2)`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
for (const part of facts.work) {
|
|
533
|
+
if (part.visible) {
|
|
534
|
+
problems.push(
|
|
535
|
+
`empty-state: ${part.name} is visible behind the start screen; it hides the work chrome (guide §8)`,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (!facts.canvasInert) {
|
|
540
|
+
problems.push(
|
|
541
|
+
"empty-state: the canvas slot is not inert behind the start screen; nothing else takes a click or the Tab (guide §8)",
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
if (!facts.legalVisible)
|
|
545
|
+
problems.push("empty-state: the legal line is not visible; a public page always shows it (guide §8)");
|
|
546
|
+
return problems;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Tab on the start screen: only the card's buttons and the legal links (guide §8). */
|
|
550
|
+
export function emptyStateTabProblems(
|
|
551
|
+
owners: ReadonlyArray<{ owner: string; description: string }>,
|
|
552
|
+
): string[] {
|
|
553
|
+
const problems: string[] = [];
|
|
554
|
+
for (const stop of owners) {
|
|
555
|
+
if (["empty-state", "legal", "toasts", "body", "outside"].includes(stop.owner)) continue;
|
|
556
|
+
problems.push(
|
|
557
|
+
`empty-state: Tab reaches ${stop.description} (${stop.owner}) behind the start screen; only the card and the legal links take the focus (guide §8)`,
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
if (!owners.some((stop) => stop.owner === "empty-state")) {
|
|
561
|
+
problems.push("empty-state: Tab never reaches the card's buttons");
|
|
562
|
+
}
|
|
563
|
+
if (!owners.some((stop) => stop.owner === "legal"))
|
|
564
|
+
problems.push("empty-state: Tab never reaches the legal links");
|
|
565
|
+
return [...new Set(problems)];
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* A chrome key behind the start screen (API.md, Empty state): it zooms no diagram, and it reaches
|
|
570
|
+
* the browser unprevented, which zooms the page (WCAG 1.4.4: there is no diagram to zoom instead).
|
|
571
|
+
*/
|
|
572
|
+
export function emptyStateKeyProblems(
|
|
573
|
+
key: string,
|
|
574
|
+
before: number | null,
|
|
575
|
+
after: number | null,
|
|
576
|
+
keys: readonly RecordedKey[],
|
|
577
|
+
): string[] {
|
|
578
|
+
const problems: string[] = [];
|
|
579
|
+
if (after !== before) {
|
|
580
|
+
problems.push(
|
|
581
|
+
`empty-state: ${key} behind the start screen changed the zoom from ${before ?? "?"}% to ${after ?? "?"}%; the chrome keys zoom no diagram there`,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
if (keys.some((recorded) => recorded.defaultPrevented)) {
|
|
585
|
+
problems.push(
|
|
586
|
+
`empty-state: ${key} behind the start screen arrived prevented; with no diagram to zoom the browser keeps its page zoom (WCAG 1.4.4)`,
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
return problems;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** After "New diagram": the card gone, the work chrome back, the focus on the canvas (guide §8). */
|
|
593
|
+
export function afterStartProblems(
|
|
594
|
+
facts: EmptyStateFacts,
|
|
595
|
+
focus: { owner: string; description: string },
|
|
596
|
+
): string[] {
|
|
597
|
+
const problems: string[] = [];
|
|
598
|
+
const after = 'after "New diagram"';
|
|
599
|
+
if (facts.shown) problems.push(`empty-state: ${after} the root still carries data-empty-state="shown"`);
|
|
600
|
+
if (facts.card?.visible) problems.push(`empty-state: ${after} the card still shows`);
|
|
601
|
+
for (const name of ["menu", "palette", "zoom"]) {
|
|
602
|
+
const part = facts.work.find((candidate) => candidate.name === name);
|
|
603
|
+
if (part && !part.visible) problems.push(`empty-state: ${after} the ${name} is still hidden`);
|
|
604
|
+
}
|
|
605
|
+
if (facts.canvasInert) problems.push(`empty-state: ${after} the canvas slot is still inert`);
|
|
606
|
+
if (focus.owner !== "canvas") {
|
|
607
|
+
problems.push(
|
|
608
|
+
`empty-state: ${after} the focus is on ${focus.description}, expected the canvas <svg> (API.md, Empty state)`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
return problems;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ---- toasts ----
|
|
615
|
+
|
|
616
|
+
/** How a toast reached screen readers: its live region existed before it, and its text arrived there. */
|
|
617
|
+
export interface ToastAnnouncement {
|
|
618
|
+
/** A live region of the toast's role was rendered in the root before the first toast. */
|
|
619
|
+
persistent: boolean;
|
|
620
|
+
/** The toast's text arrived in that region. */
|
|
621
|
+
announced: boolean;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* The live region a toast of `status` is announced through (guide §8, §12): `role="alert"` for
|
|
626
|
+
* errors, `role="status"` (`aria-live="polite"`) otherwise.
|
|
627
|
+
*/
|
|
628
|
+
export function toastLiveRegion(
|
|
629
|
+
regions: readonly LiveRegionFacts[],
|
|
630
|
+
status: ToastStatus,
|
|
631
|
+
): LiveRegionFacts | undefined {
|
|
632
|
+
const role = status === "danger" ? "alert" : "status";
|
|
633
|
+
return regions.find((region) => region.role === role);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Guide §8, §12: announced through a persistent `role="alert"` (errors) or `role="status"` live
|
|
638
|
+
* region, the card itself without a live role (it would be read twice); Close named "Close".
|
|
639
|
+
*/
|
|
640
|
+
export function toastAnatomyProblems(
|
|
641
|
+
toast: ToastFacts,
|
|
642
|
+
status: ToastStatus,
|
|
643
|
+
lang: string,
|
|
644
|
+
announcement: ToastAnnouncement,
|
|
645
|
+
): string[] {
|
|
646
|
+
const problems: string[] = [];
|
|
647
|
+
const role = status === "danger" ? "alert" : "status";
|
|
648
|
+
if (!announcement.persistent) {
|
|
649
|
+
problems.push(
|
|
650
|
+
`toasts: no role="${role}" live region in the root before the first toast; a live region created together with its text is often not announced (guide §8, §12)`,
|
|
651
|
+
);
|
|
652
|
+
} else if (!announcement.announced) {
|
|
653
|
+
problems.push(
|
|
654
|
+
`toasts: the ${status} toast's text never reaches the role="${role}" live region (guide §8, §12)`,
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
if (toast.role === "status" || toast.role === "alert") {
|
|
658
|
+
problems.push(
|
|
659
|
+
`toasts: the ${status} toast's card carries role="${toast.role}"; the live region announces it, so a live card is read twice`,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
const close = toast.buttons.at(-1);
|
|
663
|
+
if (!close || close.text !== "") {
|
|
664
|
+
problems.push(`toasts: the ${status} toast has no Close icon button at its end (guide §8)`);
|
|
665
|
+
} else if (!close.name) {
|
|
666
|
+
problems.push(`toasts: the ${status} toast's Close button has no accessible name`);
|
|
667
|
+
} else if (speaksEnglish(lang) && close.name !== defaultMessages.close) {
|
|
668
|
+
problems.push(
|
|
669
|
+
`toasts: the Close button is named "${close.name}", expected "${defaultMessages.close}" (guide §8)`,
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
if (toast.box.height < 40 - GEOMETRY_TOLERANCE_PX) {
|
|
673
|
+
problems.push(
|
|
674
|
+
`toasts: the ${status} toast is ${round(toast.box.height)}px high, at least 40px (guide §8)`,
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
return problems;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Texts of the visible toasts that contain one of `texts`, in stack order. */
|
|
681
|
+
export function shownToasts(toasts: readonly ToastFacts[], texts: readonly string[]): string[] {
|
|
682
|
+
return toasts.flatMap((toast) => texts.filter((text) => toast.text.includes(text)));
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* The stack after `shown` toasts in this order (the first with an action): at most `MAX_TOASTS`,
|
|
687
|
+
* the oldest without an action evicted, the newest at the bottom, a repeated text not stacked.
|
|
688
|
+
*/
|
|
689
|
+
export function toastStackProblems(
|
|
690
|
+
toasts: readonly ToastFacts[],
|
|
691
|
+
shown: readonly string[],
|
|
692
|
+
expected: readonly string[],
|
|
693
|
+
): string[] {
|
|
694
|
+
const problems: string[] = [];
|
|
695
|
+
const present = shownToasts(toasts, shown);
|
|
696
|
+
if (toasts.length > MAX_TOASTS)
|
|
697
|
+
problems.push(`toasts: ${toasts.length} toasts at once, at most ${MAX_TOASTS} (guide §8)`);
|
|
698
|
+
if (present.join("\u0000") !== expected.join("\u0000")) {
|
|
699
|
+
problems.push(
|
|
700
|
+
`toasts: after ${shown.length} toasts the stack shows ${present.map((text) => `"${text}"`).join(", ") || "none"}; expected ${expected.map((text) => `"${text}"`).join(", ")} (at most ${MAX_TOASTS}, the oldest without an action leaves, guide §8)`,
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
const tops = toasts
|
|
704
|
+
.filter((toast) => shown.some((text) => toast.text.includes(text)))
|
|
705
|
+
.map((toast) => toast.box.top);
|
|
706
|
+
if (tops.some((top, index) => index > 0 && top < tops[index - 1]!)) {
|
|
707
|
+
problems.push("toasts: the newest toast is not at the bottom of the stack (guide §8)");
|
|
708
|
+
}
|
|
709
|
+
return problems;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** A repeated request (status and text) restarts the shown toast instead of stacking a second. */
|
|
713
|
+
export function repeatedToastProblems(toasts: readonly ToastFacts[], text: string): string[] {
|
|
714
|
+
const count = toasts.filter((toast) => toast.text.includes(text)).length;
|
|
715
|
+
return count > 1
|
|
716
|
+
? [`toasts: a repeated "${text}" stacks ${count} toasts; it restarts the shown one (API.md, Toasts)`]
|
|
717
|
+
: [];
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** How the kit treated a toast before it looked at it again. */
|
|
721
|
+
export type ToastCondition = "plain" | "hovered" | "focused" | "resumed" | "action";
|
|
722
|
+
|
|
723
|
+
export interface ToastTiming {
|
|
724
|
+
text: string;
|
|
725
|
+
expectation: "present" | "gone";
|
|
726
|
+
/** Milliseconds since it appeared, or since pointer and focus left it (`resumed`). */
|
|
727
|
+
after: number;
|
|
728
|
+
present: boolean;
|
|
729
|
+
condition: ToastCondition;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const TOAST_RULES: Readonly<Record<ToastCondition, string>> = {
|
|
733
|
+
plain: "it stays for its lifetime",
|
|
734
|
+
hovered: "hover pauses its time",
|
|
735
|
+
focused: "focus pauses its time",
|
|
736
|
+
resumed: "it resumes with at least 1.5s left",
|
|
737
|
+
action: "a toast with an action never closes by itself",
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
/** Guide §8: closing after `toastLifetime` unless hovered or focused; a toast with an action never. */
|
|
741
|
+
export function toastTimingProblems(timings: readonly ToastTiming[]): string[] {
|
|
742
|
+
return timings.flatMap((timing) => {
|
|
743
|
+
const since =
|
|
744
|
+
timing.condition === "resumed" ? "after the pointer and the focus left it" : "after it appeared";
|
|
745
|
+
if (timing.expectation === "present" && !timing.present) {
|
|
746
|
+
return [
|
|
747
|
+
`toasts: "${timing.text}" closed within ${timing.after}ms ${since}; ${TOAST_RULES[timing.condition]} (guide §8)`,
|
|
748
|
+
];
|
|
749
|
+
}
|
|
750
|
+
if (timing.expectation === "gone" && timing.present) {
|
|
751
|
+
return [
|
|
752
|
+
`toasts: "${timing.text}" is still there ${timing.after}ms ${since}; it closes after its lifetime (guide §8)`,
|
|
753
|
+
];
|
|
754
|
+
}
|
|
755
|
+
return [];
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ---- problemsList ----
|
|
760
|
+
|
|
761
|
+
/** One ArrowDown (or End) in the open problems list: the row it should reach, and the one it did. */
|
|
762
|
+
export interface PopoverKeyStep {
|
|
763
|
+
key: string;
|
|
764
|
+
expected: number;
|
|
765
|
+
focused: { index: number; inView: boolean } | null;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Guide §5.10, §12 (WCAG 2.1.1): the keyboard reaches every row of the problems list, text rows
|
|
770
|
+
* without an element too, and each focused row scrolls into the list's visible part.
|
|
771
|
+
*/
|
|
772
|
+
export function popoverKeyboardProblems(steps: readonly PopoverKeyStep[], rowCount: number): string[] {
|
|
773
|
+
const problems: string[] = [];
|
|
774
|
+
for (const step of steps) {
|
|
775
|
+
if (step.focused?.index !== step.expected) {
|
|
776
|
+
problems.push(
|
|
777
|
+
`problems-popover: ${step.key} reaches ${step.focused ? `row ${step.focused.index + 1}` : "no row"}, expected row ${step.expected + 1} of ${rowCount}; the keyboard reads every row, text rows too (guide §5.10, WCAG 2.1.1)`,
|
|
778
|
+
);
|
|
779
|
+
} else if (!step.focused.inView) {
|
|
780
|
+
problems.push(
|
|
781
|
+
`problems-popover: row ${step.expected + 1} takes the focus on ${step.key} but stays scrolled out of the list`,
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return problems;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** The problems list once the pill opened it (guide §5.10). */
|
|
789
|
+
export function openPopoverProblems(facts: PopoverFacts, container: Size, lang: string): string[] {
|
|
790
|
+
const problems: string[] = [];
|
|
791
|
+
const popover = facts.popover;
|
|
792
|
+
if (!popover?.visible)
|
|
793
|
+
return ["problems-popover: a click on the pill shows no problems list (guide §5.10)"];
|
|
794
|
+
if (popover.attributes.role !== "dialog") {
|
|
795
|
+
problems.push(`problems-popover: role "${popover.attributes.role ?? ""}", expected "dialog"`);
|
|
796
|
+
}
|
|
797
|
+
if (popover.attributes["aria-modal"] === "true")
|
|
798
|
+
problems.push('problems-popover: aria-modal="true"; it is not modal');
|
|
799
|
+
if (!popover.title) {
|
|
800
|
+
problems.push("problems-popover: not labelled by its title (aria-labelledby)");
|
|
801
|
+
} else if (speaksEnglish(lang) && popover.title !== defaultMessages.problemsTitle) {
|
|
802
|
+
problems.push(
|
|
803
|
+
`problems-popover: titled "${popover.title}", expected "${defaultMessages.problemsTitle}" (guide §5.10)`,
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
if (facts.pill?.attributes["aria-expanded"] !== "true") {
|
|
807
|
+
problems.push(
|
|
808
|
+
`problems: aria-expanded is "${facts.pill?.attributes["aria-expanded"] ?? ""}" while the list is open, expected "true"`,
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
if (facts.rows.length === 0) {
|
|
812
|
+
problems.push("problems-popover: the list has no rows; show.problems passes items in the web host");
|
|
813
|
+
}
|
|
814
|
+
for (const row of facts.rows) {
|
|
815
|
+
if (row.height < SHELL.entry - GEOMETRY_TOLERANCE_PX) {
|
|
816
|
+
problems.push(
|
|
817
|
+
`problems-popover: the row "${row.text}" is ${round(row.height)}px high, at least ${SHELL.entry}px (guide §5.10)`,
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
if (!row.icon)
|
|
821
|
+
problems.push(`problems-popover: the row "${row.text}" has no severity icon (guide §5.10)`);
|
|
822
|
+
}
|
|
823
|
+
if (facts.focus !== "popover") {
|
|
824
|
+
problems.push(
|
|
825
|
+
`problems-popover: opening it leaves the focus on ${facts.focused}; it moves to the first row (API.md, Problems popover)`,
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
const pill = facts.pill;
|
|
829
|
+
if (pill) {
|
|
830
|
+
problems.push(
|
|
831
|
+
...boxMismatches(
|
|
832
|
+
"problems-popover",
|
|
833
|
+
popover.box,
|
|
834
|
+
{ left: SHELL.edge, bottom: container.height - pill.box.top + SHELL.gap },
|
|
835
|
+
container,
|
|
836
|
+
),
|
|
837
|
+
);
|
|
838
|
+
if (popover.box.width > Math.min(320, container.width - 2 * SHELL.edge) + GEOMETRY_TOLERANCE_PX + 1e-6) {
|
|
839
|
+
problems.push(
|
|
840
|
+
`problems-popover: width is ${round(popover.box.width)}px, at most ${round(Math.min(320, container.width - 2 * SHELL.edge))}px expected`,
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
if (popover.box.height > 320 + GEOMETRY_TOLERANCE_PX + 1e-6) {
|
|
844
|
+
problems.push(`problems-popover: height is ${round(popover.box.height)}px, at most 320px expected`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return problems;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** The problems list after a way of closing it (guide §5.10, API.md). */
|
|
851
|
+
export function closedPopoverProblems(
|
|
852
|
+
facts: PopoverFacts,
|
|
853
|
+
how: "Escape" | "an outside click" | "a row with an element",
|
|
854
|
+
): string[] {
|
|
855
|
+
const problems: string[] = [];
|
|
856
|
+
if (facts.popover?.visible) problems.push(`problems-popover: ${how} does not close the list`);
|
|
857
|
+
if (facts.pill && facts.pill.attributes["aria-expanded"] !== "false") {
|
|
858
|
+
problems.push(
|
|
859
|
+
`problems: aria-expanded is "${facts.pill.attributes["aria-expanded"] ?? ""}" after ${how}, expected "false"`,
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
if (how === "Escape" && facts.focus !== "pill") {
|
|
863
|
+
problems.push(`problems-popover: Escape leaves the focus on ${facts.focused}, expected the pill`);
|
|
864
|
+
}
|
|
865
|
+
if (how === "an outside click" && facts.focus === "pill") {
|
|
866
|
+
problems.push(
|
|
867
|
+
"problems-popover: an outside click moved the focus to the pill; it closes without moving the focus",
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
if (how === "a row with an element" && facts.focus !== "canvas") {
|
|
871
|
+
problems.push(
|
|
872
|
+
`problems-popover: a row with an element leaves the focus on ${facts.focused}, expected the canvas`,
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
return problems;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// ---- dropZone ----
|
|
879
|
+
|
|
880
|
+
export type ExpectedDrop = "over" | "forbidden" | "none";
|
|
881
|
+
|
|
882
|
+
/** One drag state of guide §9: drag-over, drop forbidden, or a drag the drop zone leaves alone. */
|
|
883
|
+
export function dropStateProblems(
|
|
884
|
+
facts: DropZoneFacts,
|
|
885
|
+
expected: ExpectedDrop,
|
|
886
|
+
context: string,
|
|
887
|
+
container: Size,
|
|
888
|
+
): string[] {
|
|
889
|
+
const problems: string[] = [];
|
|
890
|
+
const zone = facts.zone;
|
|
891
|
+
const over = facts.events.filter((event) => event.type === "dragover");
|
|
892
|
+
if (expected === "none") {
|
|
893
|
+
if (facts.state !== null)
|
|
894
|
+
problems.push(`drop-zone: ${context} sets data-drop="${facts.state}"; it is no file drag`);
|
|
895
|
+
if (zone?.visible) problems.push(`drop-zone: ${context} shows the drop zone`);
|
|
896
|
+
if (over.some((event) => event.defaultPrevented))
|
|
897
|
+
problems.push(`drop-zone: ${context} is prevented; the drop zone leaves non-file drags alone`);
|
|
898
|
+
return problems;
|
|
899
|
+
}
|
|
900
|
+
if (facts.state !== expected) {
|
|
901
|
+
problems.push(
|
|
902
|
+
`drop-zone: ${context} sets data-drop="${facts.state ?? ""}", expected "${expected}" (guide §9)`,
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
if (facts.events.some((event) => !event.defaultPrevented && event.type !== "dragleave")) {
|
|
906
|
+
problems.push(`drop-zone: ${context} is not prevented, so the browser would open the file`);
|
|
907
|
+
}
|
|
908
|
+
const effect = expected === "over" ? "copy" : "none";
|
|
909
|
+
for (const event of over) {
|
|
910
|
+
if (event.dropEffect !== effect)
|
|
911
|
+
problems.push(`drop-zone: ${context} has dropEffect "${event.dropEffect}", expected "${effect}"`);
|
|
912
|
+
}
|
|
913
|
+
if (!zone?.visible) return [...problems, `drop-zone: ${context} shows no drop zone (guide §9)`];
|
|
914
|
+
problems.push(
|
|
915
|
+
...boxMismatches("drop-zone", zone.box, { left: 12, top: 12, right: 12, bottom: 12 }, container).map(
|
|
916
|
+
(problem) => `${problem} (${context})`,
|
|
917
|
+
),
|
|
918
|
+
);
|
|
919
|
+
if (zone.ariaHidden !== "true") problems.push('drop-zone: the overlay lacks aria-hidden="true"');
|
|
920
|
+
const fill =
|
|
921
|
+
expected === "over"
|
|
922
|
+
? ["--cd-ui-hover", facts.roles.hover]
|
|
923
|
+
: ["--cd-ui-danger-flaeche", facts.roles.dangerFlaeche];
|
|
924
|
+
const border =
|
|
925
|
+
expected === "over"
|
|
926
|
+
? ["--cd-ui-interaktion", facts.roles.interaktion]
|
|
927
|
+
: ["--cd-ui-danger", facts.roles.danger];
|
|
928
|
+
if (zone.background !== fill[1]) {
|
|
929
|
+
problems.push(
|
|
930
|
+
`drop-zone: ${context} fills ${zone.background}, expected ${fill[0]} (${fill[1]}) (guide §9)`,
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
if (zone.borderStyle !== "dashed" || zone.borderWidth !== "2px" || zone.borderColor !== border[1]) {
|
|
934
|
+
problems.push(
|
|
935
|
+
`drop-zone: ${context} has a ${zone.borderWidth} ${zone.borderStyle} ${zone.borderColor} border, expected 2px dashed ${border[0]} (${border[1]}) (guide §9)`,
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
if (!facts.chip?.text) {
|
|
939
|
+
problems.push(`drop-zone: ${context} shows no chip text (guide §9)`);
|
|
940
|
+
} else if (facts.chip.background !== facts.roles.flaecheHoch) {
|
|
941
|
+
problems.push(
|
|
942
|
+
`drop-zone: the chip fills ${facts.chip.background}, expected --cd-ui-flaeche-hoch (${facts.roles.flaecheHoch})`,
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
return problems;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// ---- appearance (web) ----
|
|
949
|
+
|
|
950
|
+
/** What the web app's document carries for `choice` (guide §13.3), computed independently of the shell. */
|
|
951
|
+
export interface ExpectedWebMode {
|
|
952
|
+
/** `data-cd-theme` on `<html>`. */
|
|
953
|
+
carrier: Theme | "system";
|
|
954
|
+
/** The mode the page shows under the emulated system preference. */
|
|
955
|
+
effective: Appearance;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
export function expectedWebMode(
|
|
959
|
+
requested: Appearance,
|
|
960
|
+
themes: readonly Theme[],
|
|
961
|
+
choice: AppearanceChoice,
|
|
962
|
+
): ExpectedWebMode {
|
|
963
|
+
const kontrast = requested.kontrast ? { kontrast: requested.kontrast } : {};
|
|
964
|
+
if (!themes.includes("dark")) return { carrier: "light", effective: { theme: "light", ...kontrast } };
|
|
965
|
+
if (choice === "system") return { carrier: "system", effective: { theme: requested.theme, ...kontrast } };
|
|
966
|
+
return { carrier: choice, effective: { theme: choice, ...kontrast } };
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* `<html>`, the meta tags and the stored choice of the web app (guide §13.3): light-only static
|
|
971
|
+
* light with one `theme-color` without `media`; dark-capable `system` with `color-scheme` "light
|
|
972
|
+
* dark" and two `theme-color`s by `media`, a fixed choice stored and on both metas.
|
|
973
|
+
*/
|
|
974
|
+
export function webModeProblems(
|
|
975
|
+
facts: WebModeFacts,
|
|
976
|
+
themes: readonly Theme[],
|
|
977
|
+
choice: AppearanceChoice,
|
|
978
|
+
colors: { light: string; dark: string } | undefined,
|
|
979
|
+
): string[] {
|
|
980
|
+
const problems: string[] = [];
|
|
981
|
+
const dark = themes.includes("dark");
|
|
982
|
+
if (!dark) {
|
|
983
|
+
const metas = facts.themeColors;
|
|
984
|
+
if (metas.length !== 1 || metas[0]?.media !== null) {
|
|
985
|
+
problems.push(
|
|
986
|
+
`mode: a light-only web app carries one theme-color without media (guide §13.3); found ${metas.map((meta) => (meta.media ? `${meta.content} (${meta.media})` : meta.content)).join(", ") || "none"}`,
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
if (facts.colorScheme !== null && facts.colorScheme.split(/\s+/).includes("dark")) {
|
|
990
|
+
problems.push(`mode: a light-only web app declares color-scheme "${facts.colorScheme}"`);
|
|
991
|
+
}
|
|
992
|
+
return problems;
|
|
993
|
+
}
|
|
994
|
+
const label = choice === "system" ? "System" : choice === "dark" ? "Dark" : "Light";
|
|
995
|
+
const expectedScheme = choice === "system" ? "light dark" : choice;
|
|
996
|
+
if (facts.colorScheme !== expectedScheme) {
|
|
997
|
+
problems.push(
|
|
998
|
+
`mode: with ${label} <meta name="color-scheme"> reads "${facts.colorScheme ?? "(none)"}", expected "${expectedScheme}" (guide §13.3)`,
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
const stored = choice === "system" ? null : choice;
|
|
1002
|
+
if (!facts.storageBlocked && facts.stored !== stored) {
|
|
1003
|
+
problems.push(
|
|
1004
|
+
stored === null
|
|
1005
|
+
? `mode: with System localStorage keeps miragon.darstellung="${facts.stored ?? ""}"; System removes the entry (guide §13.3)`
|
|
1006
|
+
: `mode: with ${label} localStorage holds miragon.darstellung=${facts.stored === null ? "(none)" : `"${facts.stored}"`}, expected "${stored}" (guide §13.3)`,
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
const byMedia = (scheme: "light" | "dark") =>
|
|
1010
|
+
facts.themeColors.find((meta) => meta.media?.replace(/\s+/g, "") === `(prefers-color-scheme:${scheme})`);
|
|
1011
|
+
const light = byMedia("light");
|
|
1012
|
+
const darkMeta = byMedia("dark");
|
|
1013
|
+
if (!light || !darkMeta) {
|
|
1014
|
+
problems.push(
|
|
1015
|
+
"mode: a dark-capable web app carries two theme-color metas, one per prefers-color-scheme (guide §13.3)",
|
|
1016
|
+
);
|
|
1017
|
+
} else if (colors) {
|
|
1018
|
+
const want = choice === "system" ? colors : { light: colors[choice], dark: colors[choice] };
|
|
1019
|
+
if (light.content !== want.light || darkMeta.content !== want.dark) {
|
|
1020
|
+
problems.push(
|
|
1021
|
+
`mode: with ${label} the theme-color metas read ${light.content} (light) and ${darkMeta.content} (dark), expected ${want.light} and ${want.dark} (guide §13.3)`,
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
return problems;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** Guide §13.3, §7: one switch changes `<html>` and every meta tag in one frame, transitions off for it. */
|
|
1029
|
+
export function modeSwitchProblems(changes: readonly ModeChange[], label: string): string[] {
|
|
1030
|
+
const relevant = changes.filter((change) => ["theme", "color-scheme", "theme-color"].includes(change.part));
|
|
1031
|
+
if (relevant.length === 0) return [`mode: choosing ${label} changed nothing on <html> or its meta tags`];
|
|
1032
|
+
const problems: string[] = [];
|
|
1033
|
+
const frames = [...new Set(relevant.map((change) => change.frame))];
|
|
1034
|
+
if (frames.length > 1) {
|
|
1035
|
+
const parts = relevant.map((change) => `${change.part} in frame ${change.frame}`).join(", ");
|
|
1036
|
+
problems.push(`mode: choosing ${label} changed ${parts}; one frame for all of them (guide §13.3)`);
|
|
1037
|
+
}
|
|
1038
|
+
const first = Math.min(...frames);
|
|
1039
|
+
if (
|
|
1040
|
+
!changes.some(
|
|
1041
|
+
(change) => change.part === "no-transition" && change.value === "on" && change.frame === first,
|
|
1042
|
+
)
|
|
1043
|
+
) {
|
|
1044
|
+
problems.push(
|
|
1045
|
+
`mode: choosing ${label} switched without cd-shell-no-transition on <html> in that frame (guide §7: no colour fades)`,
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
return problems;
|
|
1049
|
+
}
|