@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
package/src/testing/checks.ts
CHANGED
|
@@ -15,9 +15,10 @@ import {
|
|
|
15
15
|
type ZoomButton,
|
|
16
16
|
} from "./facts.js";
|
|
17
17
|
import { GEOMETRY_TOLERANCE_PX, type Rectangle } from "./geometry.js";
|
|
18
|
-
import type { ShellConformanceHarness, ShowHook } from "./harness.js";
|
|
18
|
+
import type { AbsentFeature, ShellConformanceHarness, ShowHook } from "./harness.js";
|
|
19
19
|
import type { ElementFacts, LeftoverFacts, RecordedKey } from "./page-tools.js";
|
|
20
20
|
import type { PageLike } from "./page.js";
|
|
21
|
+
import { legalAriaProblems, primaryAriaProblems } from "./web-checks.js";
|
|
21
22
|
|
|
22
23
|
/*
|
|
23
24
|
* The judgements of the cases, as pure functions of what the probes saw. Each returns readable
|
|
@@ -177,6 +178,7 @@ export function ariaProblems(facts: AriaFacts, host: ShellHost): string[] {
|
|
|
177
178
|
for (const element of facts.titled) {
|
|
178
179
|
problems.push(`${element} has a native title; the chrome tooltip replaces it (guide §5.6)`);
|
|
179
180
|
}
|
|
181
|
+
problems.push(...primaryAriaProblems(facts, host), ...legalAriaProblems(facts, host));
|
|
180
182
|
return problems;
|
|
181
183
|
}
|
|
182
184
|
|
|
@@ -245,13 +247,19 @@ export interface TabStop {
|
|
|
245
247
|
/**
|
|
246
248
|
* Tab from the menu trigger (guide §5.14): the primary action (web app), the palette's one tab
|
|
247
249
|
* stop, then the canvas `<svg>`; nothing else in between (a renderer's own `tabindex` on
|
|
248
|
-
* `.djs-container` would be an extra, unnamed stop).
|
|
250
|
+
* `.djs-container` would be an extra, unnamed stop). With `primary` the web app shows one, and
|
|
251
|
+
* Tab from the trigger lands on it first.
|
|
249
252
|
*/
|
|
250
|
-
export function tabSequenceProblems(stops: readonly TabStop[]): string[] {
|
|
253
|
+
export function tabSequenceProblems(stops: readonly TabStop[], primary = false): string[] {
|
|
251
254
|
const [first, second, third] = stops;
|
|
252
255
|
const sequence = first?.kind === "primary" ? [second, third] : [first, second];
|
|
253
256
|
const [palette, canvas] = sequence;
|
|
254
257
|
const problems: string[] = [];
|
|
258
|
+
if (primary && first?.kind !== "primary") {
|
|
259
|
+
problems.push(
|
|
260
|
+
`tab order: Tab from the menu trigger lands on ${first?.description ?? "nothing"}, expected the primary action (top left, top right, top centre, guide §5.14)`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
255
263
|
if (palette?.kind !== "palette") {
|
|
256
264
|
problems.push(
|
|
257
265
|
`tab order: Tab from the menu trigger lands on ${palette?.description ?? "nothing"}, expected the palette's roving entry (guide §5.14)`,
|
|
@@ -431,21 +439,37 @@ export function expectedEmbeddedAppearance(facts: AppearanceFacts, themes: reado
|
|
|
431
439
|
return host?.kontrast === "hoch" ? { theme: fallback, kontrast: "hoch" } : { theme: fallback };
|
|
432
440
|
}
|
|
433
441
|
|
|
442
|
+
/**
|
|
443
|
+
* The web app's own expectation (guide §13.3): `<html>` carries `carrier` (`system` for a
|
|
444
|
+
* dark-capable modeler without a stored choice) and the page shows `effective` under the emulated
|
|
445
|
+
* system preference.
|
|
446
|
+
*/
|
|
447
|
+
export interface WebModeExpectation {
|
|
448
|
+
carrier: string;
|
|
449
|
+
effective: Appearance;
|
|
450
|
+
}
|
|
451
|
+
|
|
434
452
|
/** No half-dark (guide §13.3, §13.5): one mode on `<html>` (own document) or the root (embedded). */
|
|
435
453
|
export function appearanceProblems(
|
|
436
454
|
facts: AppearanceFacts,
|
|
437
455
|
host: ShellHost,
|
|
438
456
|
requested: Appearance,
|
|
439
457
|
themes: readonly Theme[],
|
|
458
|
+
web?: WebModeExpectation,
|
|
440
459
|
): AppearanceVerdict {
|
|
441
460
|
const ownDocument = host !== "embedded";
|
|
442
|
-
const expected =
|
|
443
|
-
|
|
444
|
-
: expectedEmbeddedAppearance(facts, themes);
|
|
461
|
+
const expected =
|
|
462
|
+
web?.effective ??
|
|
463
|
+
(ownDocument ? expectedAppearance(requested, themes) : expectedEmbeddedAppearance(facts, themes));
|
|
464
|
+
const carrierTheme = web?.carrier ?? expected.theme;
|
|
445
465
|
const problems: string[] = [];
|
|
466
|
+
const described =
|
|
467
|
+
carrierTheme === expected.theme
|
|
468
|
+
? describeAppearance(expected)
|
|
469
|
+
: `${carrierTheme} showing ${describeAppearance(expected)}`;
|
|
446
470
|
const label = ownDocument
|
|
447
|
-
? `requested ${describeAppearance(requested)}, expected ${
|
|
448
|
-
: `host page ${describeAppearance(requested)}, expected ${
|
|
471
|
+
? `requested ${describeAppearance(requested)}, expected ${described}`
|
|
472
|
+
: `host page ${describeAppearance(requested)}, expected ${described}`;
|
|
449
473
|
|
|
450
474
|
const carrier = ownDocument
|
|
451
475
|
? facts.themed.find((element) => element.isDocument && element.theme !== null)
|
|
@@ -457,7 +481,7 @@ export function appearanceProblems(
|
|
|
457
481
|
: `the embedded root has no data-cd-theme (${label}): it takes the host page's mode instead of its own (guide §13.3)`,
|
|
458
482
|
);
|
|
459
483
|
} else {
|
|
460
|
-
if (carrier.theme !==
|
|
484
|
+
if (carrier.theme !== carrierTheme) {
|
|
461
485
|
problems.push(`${carrier.description} has data-cd-theme="${carrier.theme}" (${label})`);
|
|
462
486
|
}
|
|
463
487
|
if ((carrier.kontrast ?? undefined) !== expected.kontrast) {
|
|
@@ -534,7 +558,7 @@ export function appearanceProblems(
|
|
|
534
558
|
return { problems };
|
|
535
559
|
}
|
|
536
560
|
|
|
537
|
-
/** The root attributes the shell writes (API.md, DOM). */
|
|
561
|
+
/** The root attributes the shell writes (API.md, DOM; the web host's state attributes included). */
|
|
538
562
|
const SHELL_ROOT_ATTRIBUTES = [
|
|
539
563
|
"data-cd-shell-host",
|
|
540
564
|
"data-palette",
|
|
@@ -542,6 +566,10 @@ const SHELL_ROOT_ATTRIBUTES = [
|
|
|
542
566
|
"data-inspector",
|
|
543
567
|
"data-problems",
|
|
544
568
|
"data-readonly",
|
|
569
|
+
"data-primary",
|
|
570
|
+
"data-zoom-row",
|
|
571
|
+
"data-empty-state",
|
|
572
|
+
"data-drop",
|
|
545
573
|
];
|
|
546
574
|
|
|
547
575
|
/** What an embedded root carries on top (its own mode). */
|
|
@@ -589,14 +617,14 @@ export type AbsentIsland = Extract<ShowHook, "legend" | "inspector" | "problems"
|
|
|
589
617
|
const ABSENT_ISLANDS: readonly AbsentIsland[] = ["legend", "inspector", "problems", "status"];
|
|
590
618
|
|
|
591
619
|
/** The islands of `absent` (its state `invalid` has no island). */
|
|
592
|
-
export function absentIslands(absent: readonly
|
|
620
|
+
export function absentIslands(absent: readonly AbsentFeature[] | undefined): AbsentIsland[] {
|
|
593
621
|
return ABSENT_ISLANDS.filter((island) => absent?.includes(island));
|
|
594
622
|
}
|
|
595
623
|
|
|
596
624
|
/** An island the harness declares absent must never show. */
|
|
597
625
|
export function absentIslandProblems(
|
|
598
626
|
visible: ReadonlySet<string>,
|
|
599
|
-
absent: readonly
|
|
627
|
+
absent: readonly AbsentFeature[] | undefined,
|
|
600
628
|
): string[] {
|
|
601
629
|
return absentIslands(absent)
|
|
602
630
|
.filter((island) => visible.has(island))
|
|
@@ -606,11 +634,19 @@ export function absentIslandProblems(
|
|
|
606
634
|
/** A hook the harness both gives and lists as absent is a contradiction in the harness itself. */
|
|
607
635
|
export function harnessHookProblems<P extends PageLike>(
|
|
608
636
|
harness: ShellConformanceHarness<P>,
|
|
609
|
-
hooks: readonly
|
|
637
|
+
hooks: readonly AbsentFeature[],
|
|
610
638
|
): string[] {
|
|
611
639
|
return hooks
|
|
612
|
-
.filter((hook) =>
|
|
613
|
-
|
|
640
|
+
.filter((hook) =>
|
|
641
|
+
hook === "drop"
|
|
642
|
+
? harness.absent?.includes("drop") && harness.drop
|
|
643
|
+
: hook !== "problemSelection" && harness.absent?.includes(hook) && harness.show?.[hook],
|
|
644
|
+
)
|
|
645
|
+
.map((hook) =>
|
|
646
|
+
hook === "drop"
|
|
647
|
+
? 'harness: absent lists "drop", but harness.drop is given'
|
|
648
|
+
: `harness: absent lists "${hook}", but show.${hook} is given`,
|
|
649
|
+
);
|
|
614
650
|
}
|
|
615
651
|
|
|
616
652
|
/** Guide §10.1: the surround of the canvas is a container painted in `--cd-ui-grund`, in both modes. */
|
package/src/testing/facts.ts
CHANGED
|
@@ -7,7 +7,11 @@ import type { ElementFacts, RelativeBox, ToolWindow } from "./page-tools.js";
|
|
|
7
7
|
|
|
8
8
|
export type ZoomButton = "out" | "actual" | "in" | "fit";
|
|
9
9
|
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* The chrome parts in the DOM (and tab) order of guide §5.14; the web app adds the primary action
|
|
12
|
+
* after the menu trigger, the start screen card after the hint, the legal line before the zoom
|
|
13
|
+
* panel and the toast stack last (API.md, Web host).
|
|
14
|
+
*/
|
|
11
15
|
export const DOM_ORDER = [
|
|
12
16
|
"menu",
|
|
13
17
|
"primary",
|
|
@@ -15,10 +19,13 @@ export const DOM_ORDER = [
|
|
|
15
19
|
"canvas",
|
|
16
20
|
"status",
|
|
17
21
|
"hint",
|
|
22
|
+
"empty-state",
|
|
18
23
|
"inspector",
|
|
19
24
|
"problems",
|
|
20
25
|
"legend",
|
|
26
|
+
"legal",
|
|
21
27
|
"zoom",
|
|
28
|
+
"toasts",
|
|
22
29
|
] as const;
|
|
23
30
|
|
|
24
31
|
export type OrderedPart = (typeof DOM_ORDER)[number];
|
|
@@ -27,6 +34,15 @@ export interface PaletteEntryFacts extends ElementFacts {
|
|
|
27
34
|
group: string | null;
|
|
28
35
|
}
|
|
29
36
|
|
|
37
|
+
/** A link of the web app's legal line. */
|
|
38
|
+
export interface LinkFacts {
|
|
39
|
+
text: string;
|
|
40
|
+
href: string;
|
|
41
|
+
lang: string | null;
|
|
42
|
+
target: string | null;
|
|
43
|
+
rel: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
30
46
|
export interface AriaFacts {
|
|
31
47
|
platform: "mac" | "other";
|
|
32
48
|
documentLang: string;
|
|
@@ -54,6 +70,15 @@ export interface AriaFacts {
|
|
|
54
70
|
legendTitle: string | null;
|
|
55
71
|
legendClose: ElementFacts | null;
|
|
56
72
|
inspector: ElementFacts | null;
|
|
73
|
+
/** The web app's primary action (`[data-chrome="primary"]`). */
|
|
74
|
+
primary: ElementFacts | null;
|
|
75
|
+
/** The root's `data-primary="collapsed"`. */
|
|
76
|
+
primaryCollapsed: boolean;
|
|
77
|
+
/** The web app's legal line and its links, in order. */
|
|
78
|
+
legal: ElementFacts | null;
|
|
79
|
+
legalLinks: LinkFacts[];
|
|
80
|
+
/** The element the pill's `aria-controls` names (the web app's problems list). */
|
|
81
|
+
problemsControlled: ElementFacts | null;
|
|
57
82
|
}
|
|
58
83
|
|
|
59
84
|
export const ariaProbe = ({ root }: { root: string }): AriaFacts | null => {
|
|
@@ -76,6 +101,8 @@ export const ariaProbe = ({ root }: { root: string }): AriaFacts | null => {
|
|
|
76
101
|
}
|
|
77
102
|
const legend = find('[data-chrome="legend"]');
|
|
78
103
|
const labelledBy = legend?.getAttribute("aria-labelledby");
|
|
104
|
+
const legal = find('[data-chrome="legal"]');
|
|
105
|
+
const pillControls = find('[data-chrome="problems"]')?.getAttribute("aria-controls");
|
|
79
106
|
const parts: Array<[string, Element | null]> = [
|
|
80
107
|
["menu", trigger],
|
|
81
108
|
["primary", find('[data-chrome="primary"]')],
|
|
@@ -85,13 +112,17 @@ export const ariaProbe = ({ root }: { root: string }): AriaFacts | null => {
|
|
|
85
112
|
["hint", find(".cd-shell-hint")],
|
|
86
113
|
["inspector", find('[data-chrome="inspector"]')],
|
|
87
114
|
["problems", find('[data-chrome="problems"]')],
|
|
115
|
+
["empty-state", find('[data-chrome="empty-state"]')],
|
|
88
116
|
["legend", legend],
|
|
117
|
+
["legal", find('[data-chrome="legal"]')],
|
|
89
118
|
["zoom", zoom],
|
|
119
|
+
["toasts", find('[data-chrome="toasts"]')],
|
|
90
120
|
];
|
|
91
121
|
const present = parts.filter((part): part is [string, Element] => part[1] !== null);
|
|
92
122
|
present.sort(([, a], [, b]) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
|
|
93
123
|
const titledSelector = [
|
|
94
124
|
'[data-chrome="menu"][title]',
|
|
125
|
+
'[data-chrome="primary"][title]',
|
|
95
126
|
'[data-chrome="palette"] [data-action][title]',
|
|
96
127
|
'[data-chrome="zoom"] [title]',
|
|
97
128
|
'[data-chrome="problems"][title]',
|
|
@@ -130,6 +161,17 @@ export const ariaProbe = ({ root }: { root: string }): AriaFacts | null => {
|
|
|
130
161
|
: null,
|
|
131
162
|
legendClose: factsOf(legend?.querySelector("button")),
|
|
132
163
|
inspector: factsOf(find('[data-chrome="inspector"]')),
|
|
164
|
+
primary: factsOf(find('[data-chrome="primary"]')),
|
|
165
|
+
primaryCollapsed: rootElement.getAttribute("data-primary") === "collapsed",
|
|
166
|
+
legal: factsOf(legal),
|
|
167
|
+
legalLinks: [...(legal?.querySelectorAll("a") ?? [])].map((link) => ({
|
|
168
|
+
text: (link.textContent ?? "").replace(/\s+/g, " ").trim(),
|
|
169
|
+
href: link.getAttribute("href") ?? "",
|
|
170
|
+
lang: link.getAttribute("lang"),
|
|
171
|
+
target: link.getAttribute("target"),
|
|
172
|
+
rel: link.getAttribute("rel"),
|
|
173
|
+
})),
|
|
174
|
+
problemsControlled: factsOf(pillControls ? document.getElementById(pillControls) : null),
|
|
133
175
|
};
|
|
134
176
|
};
|
|
135
177
|
|
|
@@ -559,8 +601,17 @@ export interface MenuEntryFacts {
|
|
|
559
601
|
/** `data-menu-id` of the item or its radio group. */
|
|
560
602
|
menuId: string | null;
|
|
561
603
|
disabled: boolean;
|
|
562
|
-
/**
|
|
604
|
+
/**
|
|
605
|
+
* `data-menu-group` of the item or its radio group: the shell's `model`, `export` and `help`,
|
|
606
|
+
* in the web app also `file` and `appearance`.
|
|
607
|
+
*/
|
|
563
608
|
group: string | null;
|
|
609
|
+
/** `data-menu-value` of a radio option. */
|
|
610
|
+
value: string | null;
|
|
611
|
+
/** `aria-checked` of a checkbox or radio entry. */
|
|
612
|
+
checked: string | null;
|
|
613
|
+
/** Whether the entry's icon column holds an icon (guide §5.4, only the common set of §5.14). */
|
|
614
|
+
icon: boolean;
|
|
564
615
|
}
|
|
565
616
|
|
|
566
617
|
export const menuEntriesProbe = ({ root }: { root: string }): MenuEntryFacts[] | null => {
|
|
@@ -581,6 +632,9 @@ export const menuEntriesProbe = ({ root }: { root: string }): MenuEntryFacts[] |
|
|
|
581
632
|
menuId: element.closest("[data-menu-id]")?.getAttribute("data-menu-id") ?? null,
|
|
582
633
|
disabled: element.getAttribute("aria-disabled") === "true",
|
|
583
634
|
group: element.closest("[data-menu-group]")?.getAttribute("data-menu-group") ?? null,
|
|
635
|
+
value: element.getAttribute("data-menu-value"),
|
|
636
|
+
checked: element.getAttribute("aria-checked"),
|
|
637
|
+
icon: element.querySelector(".cd-shell-menu-item-icon svg") !== null,
|
|
584
638
|
});
|
|
585
639
|
}
|
|
586
640
|
return entries;
|
package/src/testing/geometry.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CHROME_ISLANDS,
|
|
3
|
+
LEGAL_LINE_HEIGHT,
|
|
3
4
|
PALETTE_CLEARANCE,
|
|
4
5
|
SHELL,
|
|
6
|
+
legalLineHeight,
|
|
7
|
+
toastStackBottom,
|
|
8
|
+
toastStackWidth,
|
|
9
|
+
zoomRowRaised,
|
|
10
|
+
type BottomObstacle,
|
|
5
11
|
type ChromeIsland,
|
|
6
12
|
type Insets,
|
|
7
13
|
type PaletteRow,
|
|
@@ -31,8 +37,21 @@ export interface ChromeMeasurement {
|
|
|
31
37
|
paletteRow: PaletteRow;
|
|
32
38
|
/** The visible islands, found by `[data-chrome]`. */
|
|
33
39
|
islands: Partial<Record<ChromeIsland, IslandBox>>;
|
|
34
|
-
/**
|
|
35
|
-
|
|
40
|
+
/**
|
|
41
|
+
* State attributes of the root: `[data-legend="open"]`, `[data-inspector="open"]`,
|
|
42
|
+
* `[data-problems="shown"]`, and in the web host `[data-empty-state="shown"]`,
|
|
43
|
+
* `[data-primary="collapsed"]` and `[data-zoom-row="raised"]`.
|
|
44
|
+
*/
|
|
45
|
+
open: {
|
|
46
|
+
legend: boolean;
|
|
47
|
+
inspector: boolean;
|
|
48
|
+
problems: boolean;
|
|
49
|
+
emptyState: boolean;
|
|
50
|
+
primaryCollapsed: boolean;
|
|
51
|
+
zoomRaised: boolean;
|
|
52
|
+
};
|
|
53
|
+
/** Width of the widest visible toast card (`[data-status]` in the stack); absent without a toast. */
|
|
54
|
+
toastCardWidth?: number;
|
|
36
55
|
}
|
|
37
56
|
|
|
38
57
|
/** Constraints of one island; every given field must hold within `GEOMETRY_TOLERANCE_PX`. */
|
|
@@ -45,6 +64,8 @@ export interface ExpectedBox {
|
|
|
45
64
|
height?: number;
|
|
46
65
|
/** Horizontal centre relative to the root. */
|
|
47
66
|
centerX?: number;
|
|
67
|
+
/** Vertical centre relative to the root (the web app's empty-state card). */
|
|
68
|
+
centerY?: number;
|
|
48
69
|
}
|
|
49
70
|
|
|
50
71
|
export const DEFAULT_ROOT = ".cd-shell";
|
|
@@ -52,8 +73,15 @@ export const DEFAULT_ROOT = ".cd-shell";
|
|
|
52
73
|
/** `[−] [NN%] [+] | [Fit]` with a 56px readout (guide §5.1, §5.8): 1+3+32+2+56+2+32+2+5+2+32+3+1. */
|
|
53
74
|
export const ZOOM_PANEL_WIDTH = 173;
|
|
54
75
|
|
|
55
|
-
/** Height of the web app's legal line under the bottom-left stack (guide §5.10). */
|
|
56
|
-
const LEGAL_LINE =
|
|
76
|
+
/** Height of one line of the web app's legal line under the bottom-left stack (guide §5.10). */
|
|
77
|
+
export const LEGAL_LINE = LEGAL_LINE_HEIGHT;
|
|
78
|
+
|
|
79
|
+
/** The web app's start screen card: `min(400px, 100% − 24px)` wide (guide §8). */
|
|
80
|
+
const EMPTY_STATE_MAX_WIDTH = 400;
|
|
81
|
+
|
|
82
|
+
/** The web app's problems list above the pill: `min(320px, 100% − 24px)` wide, at most 320 high (guide §5.10). */
|
|
83
|
+
const POPOVER_MAX_WIDTH = 320;
|
|
84
|
+
const POPOVER_MAX_HEIGHT = 320;
|
|
57
85
|
|
|
58
86
|
/** Minimum width of the legend card (guide §5.10). */
|
|
59
87
|
const LEGEND_MIN_WIDTH = 184;
|
|
@@ -80,7 +108,11 @@ const measureProbe = ({ root, islands }: { root: string; islands: readonly strin
|
|
|
80
108
|
}
|
|
81
109
|
boxes[name] = tools.box(element, rootElement);
|
|
82
110
|
}
|
|
111
|
+
const cardWidths = [...rootElement.querySelectorAll<HTMLElement>('[data-chrome="toasts"] > [data-status]')]
|
|
112
|
+
.filter((card) => tools.visible(card))
|
|
113
|
+
.map((card) => card.getBoundingClientRect().width);
|
|
83
114
|
return {
|
|
115
|
+
...(cardWidths.length > 0 ? { toastCardWidth: Math.max(...cardWidths) } : {}),
|
|
84
116
|
container: tools.container(rootElement),
|
|
85
117
|
paletteRow: rootElement.getAttribute("data-palette") === "second-row" ? "second-row" : "top",
|
|
86
118
|
islands: boxes,
|
|
@@ -88,6 +120,9 @@ const measureProbe = ({ root, islands }: { root: string; islands: readonly strin
|
|
|
88
120
|
legend: rootElement.getAttribute("data-legend") === "open",
|
|
89
121
|
inspector: rootElement.getAttribute("data-inspector") === "open",
|
|
90
122
|
problems: rootElement.getAttribute("data-problems") === "shown",
|
|
123
|
+
emptyState: rootElement.getAttribute("data-empty-state") === "shown",
|
|
124
|
+
primaryCollapsed: rootElement.getAttribute("data-primary") === "collapsed",
|
|
125
|
+
zoomRaised: rootElement.getAttribute("data-zoom-row") === "raised",
|
|
91
126
|
},
|
|
92
127
|
duplicates,
|
|
93
128
|
};
|
|
@@ -106,6 +141,7 @@ export async function measureChrome(page: PageLike, root: string = DEFAULT_ROOT)
|
|
|
106
141
|
paletteRow: result.paletteRow,
|
|
107
142
|
islands: result.islands,
|
|
108
143
|
open: result.open,
|
|
144
|
+
...(result.toastCardWidth !== undefined ? { toastCardWidth: result.toastCardWidth } : {}),
|
|
109
145
|
};
|
|
110
146
|
}
|
|
111
147
|
|
|
@@ -142,28 +178,63 @@ function sheetOpen(measurement: ChromeMeasurement): boolean {
|
|
|
142
178
|
return measurement.open.inspector && isNarrow(measurement.container);
|
|
143
179
|
}
|
|
144
180
|
|
|
181
|
+
/** The bottom of an island as `toastStackBottom` sees it (guide §8, the shell's rule for the bottom row). */
|
|
182
|
+
export function bottomObstacle(box: IslandBox, container: Size): BottomObstacle {
|
|
183
|
+
return { left: box.left, right: box.left + box.width, top: container.height - box.top };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Why an island of the web column never shows in the IDE and embedded hosts (guide §5.2). */
|
|
187
|
+
const WEB_ONLY: Partial<Record<ChromeIsland, string>> = {
|
|
188
|
+
primary: "the top right stays empty in IDE and embedded hosts (guide §5.7)",
|
|
189
|
+
legal: "the legal line belongs to the web app; an IDE or an embedding host carries its own (guide §5.2)",
|
|
190
|
+
"empty-state": "IDE and embedded hosts have no start screen; they show the empty-file hint (guide §13.5)",
|
|
191
|
+
"drop-zone": "IDE and embedded hosts have no drop zone; their files belong to the host (guide §5.2)",
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/** The work chrome the web app's start screen hides (guide §8: only the card and the legal line show). */
|
|
195
|
+
export const WORK_CHROME: readonly ChromeIsland[] = [
|
|
196
|
+
"menu",
|
|
197
|
+
"primary",
|
|
198
|
+
"palette",
|
|
199
|
+
"zoom",
|
|
200
|
+
"problems",
|
|
201
|
+
"legend",
|
|
202
|
+
"status",
|
|
203
|
+
"inspector",
|
|
204
|
+
];
|
|
205
|
+
|
|
145
206
|
/** The anchors and limits of guide §5.2 to §5.15 for `measurement` in `host`. */
|
|
146
207
|
export function chromeExpectations(measurement: ChromeMeasurement, host: ShellHost): ChromeExpectations {
|
|
147
208
|
const { container, islands, open, paletteRow } = measurement;
|
|
148
209
|
const { edge, gap, row, free, inspectorWidth } = SHELL;
|
|
149
210
|
const web = host === "web";
|
|
211
|
+
const startScreen = web && open.emptyState;
|
|
212
|
+
const raised = web && open.zoomRaised;
|
|
150
213
|
const secondRow = paletteRow === "second-row";
|
|
151
214
|
const sheet = sheetOpen(measurement);
|
|
152
215
|
const sideInspector = open.inspector && !isNarrow(container);
|
|
216
|
+
// A narrow container wraps the legal line; the stack above it stands on its whole lines.
|
|
217
|
+
const legalHeight = legalLineHeight(web ? islands.legal?.height : undefined);
|
|
153
218
|
const boxes: Partial<Record<ChromeIsland, ExpectedBox>> = {};
|
|
154
219
|
const limits: Partial<Record<ChromeIsland, BoxLimits>> = {};
|
|
155
220
|
const hidden: Partial<Record<ChromeIsland, string>> = {};
|
|
156
|
-
|
|
221
|
+
// Behind the start screen the work chrome is hidden; where it shows anyway, it still stands at its anchor.
|
|
222
|
+
const required: ChromeIsland[] = startScreen ? ["empty-state"] : ["menu", "palette"];
|
|
223
|
+
if (web) required.push("legal");
|
|
157
224
|
|
|
158
225
|
boxes.menu = { left: edge, top: edge, width: row, height: row };
|
|
159
226
|
|
|
160
227
|
boxes.palette = { top: secondRow ? free : edge, height: row, centerX: container.width / 2 };
|
|
161
228
|
const menu = islands.menu;
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
:
|
|
165
|
-
|
|
166
|
-
|
|
229
|
+
const primary = web ? islands.primary : undefined;
|
|
230
|
+
if (secondRow) {
|
|
231
|
+
limits.palette = { maxWidth: container.width - 2 * edge };
|
|
232
|
+
} else {
|
|
233
|
+
limits.palette = {};
|
|
234
|
+
if (menu) limits.palette.minLeft = menu.left + menu.width + PALETTE_CLEARANCE;
|
|
235
|
+
// Guide §5.15: 16px to every island of the top row, the primary action included.
|
|
236
|
+
if (primary) limits.palette.minRight = container.width - primary.left + PALETTE_CLEARANCE;
|
|
237
|
+
}
|
|
167
238
|
|
|
168
239
|
if (sheet) {
|
|
169
240
|
const reason = "hidden while the inspector is a bottom sheet (guide §5.11)";
|
|
@@ -171,13 +242,15 @@ export function chromeExpectations(measurement: ChromeMeasurement, host: ShellHo
|
|
|
171
242
|
hidden.problems = reason;
|
|
172
243
|
hidden.legend = reason;
|
|
173
244
|
} else {
|
|
174
|
-
required.push("zoom");
|
|
175
|
-
|
|
176
|
-
|
|
245
|
+
if (!startScreen) required.push("zoom");
|
|
246
|
+
const legalStack = edge + legalHeight + gap;
|
|
247
|
+
boxes.zoom = { right: edge, bottom: raised ? legalStack : edge, width: ZOOM_PANEL_WIDTH, height: row };
|
|
248
|
+
const stackBase = web ? legalStack : edge;
|
|
177
249
|
if (islands.problems) boxes.problems = { left: edge, bottom: stackBase, height: row };
|
|
178
250
|
if (islands.legend) {
|
|
179
251
|
const besidePill = open.problems ? stackBase + row + gap : stackBase;
|
|
180
|
-
const
|
|
252
|
+
const aboveBottomRow = (raised ? legalStack : edge) + row + gap;
|
|
253
|
+
const bottom = isVeryNarrow(container) ? Math.max(besidePill, aboveBottomRow) : besidePill;
|
|
181
254
|
boxes.legend = { left: edge, bottom };
|
|
182
255
|
limits.legend = { minWidth: LEGEND_MIN_WIDTH };
|
|
183
256
|
}
|
|
@@ -203,9 +276,54 @@ export function chromeExpectations(measurement: ChromeMeasurement, host: ShellHo
|
|
|
203
276
|
}
|
|
204
277
|
|
|
205
278
|
if (web) {
|
|
206
|
-
if (
|
|
279
|
+
if (primary) {
|
|
280
|
+
boxes.primary = { top: edge, right: edge, height: row };
|
|
281
|
+
if (open.primaryCollapsed) boxes.primary.width = row;
|
|
282
|
+
}
|
|
283
|
+
boxes.legal = { left: edge, bottom: edge, height: legalHeight };
|
|
284
|
+
limits.legal = { maxWidth: container.width - 2 * edge };
|
|
285
|
+
if (islands["empty-state"]) {
|
|
286
|
+
boxes["empty-state"] = {
|
|
287
|
+
centerX: container.width / 2,
|
|
288
|
+
centerY: container.height / 2,
|
|
289
|
+
width: Math.min(EMPTY_STATE_MAX_WIDTH, container.width - 2 * edge),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (islands["drop-zone"]) boxes["drop-zone"] = { left: edge, top: edge, right: edge, bottom: edge };
|
|
207
293
|
} else {
|
|
208
|
-
|
|
294
|
+
Object.assign(hidden, WEB_ONLY);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (host === "ide") {
|
|
298
|
+
hidden.toasts = "the IDE host shows no toasts; the extension shows the host's notification (guide §13.5)";
|
|
299
|
+
hidden["problems-popover"] =
|
|
300
|
+
"the IDE host opens its Problems view, never a list of its own (guide §5.10)";
|
|
301
|
+
} else {
|
|
302
|
+
if (islands.toasts) {
|
|
303
|
+
const obstacles = (["zoom", "legal", "problems"] as const).flatMap((name) => {
|
|
304
|
+
const box = islands[name];
|
|
305
|
+
return box ? [bottomObstacle(box, container)] : [];
|
|
306
|
+
});
|
|
307
|
+
boxes.toasts = {
|
|
308
|
+
centerX: container.width / 2,
|
|
309
|
+
width: toastStackWidth(container.width),
|
|
310
|
+
bottom: toastStackBottom({
|
|
311
|
+
containerWidth: container.width,
|
|
312
|
+
obstacles,
|
|
313
|
+
cardWidth: measurement.toastCardWidth,
|
|
314
|
+
}),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
const pill = islands.problems;
|
|
318
|
+
if (pill) {
|
|
319
|
+
boxes["problems-popover"] = { left: edge, bottom: container.height - pill.top + gap };
|
|
320
|
+
limits["problems-popover"] = {
|
|
321
|
+
maxWidth: Math.min(POPOVER_MAX_WIDTH, container.width - 2 * edge),
|
|
322
|
+
maxHeight: POPOVER_MAX_HEIGHT,
|
|
323
|
+
};
|
|
324
|
+
} else {
|
|
325
|
+
hidden["problems-popover"] = "the problems list opens only above a visible pill (guide §5.10)";
|
|
326
|
+
}
|
|
209
327
|
}
|
|
210
328
|
|
|
211
329
|
return { boxes, limits, required, hidden };
|
|
@@ -230,6 +348,7 @@ function sides(box: IslandBox, container: Size): Required<ExpectedBox> {
|
|
|
230
348
|
width: box.width,
|
|
231
349
|
height: box.height,
|
|
232
350
|
centerX: box.left + box.width / 2,
|
|
351
|
+
centerY: box.top + box.height / 2,
|
|
233
352
|
};
|
|
234
353
|
}
|
|
235
354
|
|
|
@@ -319,12 +438,22 @@ export function geometryProblems(
|
|
|
319
438
|
}
|
|
320
439
|
const palette = islands.palette;
|
|
321
440
|
const menu = islands.menu;
|
|
441
|
+
const primary = host === "web" ? islands.primary : undefined;
|
|
322
442
|
if (paletteRow === "second-row" && !isNarrow(container) && palette && menu) {
|
|
323
443
|
const clipped = palette.width >= container.width - 2 * SHELL.edge - tolerance;
|
|
324
444
|
const topRowLeft = (container.width - palette.width) / 2;
|
|
325
|
-
|
|
445
|
+
const toMenu = topRowLeft - menu.left - menu.width;
|
|
446
|
+
// Guide §5.15: the primary action loses its label before the palette moves, so it counts icon only.
|
|
447
|
+
const toPrimary = primary
|
|
448
|
+
? container.width - SHELL.edge - SHELL.row - (topRowLeft + palette.width)
|
|
449
|
+
: Number.POSITIVE_INFINITY;
|
|
450
|
+
const fits = toMenu >= PALETTE_CLEARANCE + tolerance && toPrimary >= PALETTE_CLEARANCE + tolerance;
|
|
451
|
+
if (!clipped && fits) {
|
|
452
|
+
const kept = primary
|
|
453
|
+
? `${round(toMenu)}px to the menu trigger and ${round(toPrimary)}px to the primary action's icon`
|
|
454
|
+
: `${round(toMenu)}px to the menu trigger`;
|
|
326
455
|
problems.push(
|
|
327
|
-
`palette: in the second row although the top row keeps ${
|
|
456
|
+
`palette: in the second row although the top row keeps ${kept} (at least ${PALETTE_CLEARANCE}px suffice, guide §5.15)`,
|
|
328
457
|
);
|
|
329
458
|
}
|
|
330
459
|
}
|
|
@@ -336,16 +465,62 @@ export function geometryProblems(
|
|
|
336
465
|
}
|
|
337
466
|
if (!flag && shown) problems.push(`${island}: visible, but the root lacks ${attribute}`);
|
|
338
467
|
};
|
|
339
|
-
state("problems", open.problems, 'data-problems="shown"', sheet);
|
|
340
|
-
state("legend", open.legend, 'data-legend="open"', sheet);
|
|
341
|
-
state("inspector", open.inspector, 'data-inspector="open"',
|
|
468
|
+
state("problems", open.problems, 'data-problems="shown"', sheet || open.emptyState);
|
|
469
|
+
state("legend", open.legend, 'data-legend="open"', sheet || open.emptyState);
|
|
470
|
+
state("inspector", open.inspector, 'data-inspector="open"', open.emptyState);
|
|
471
|
+
state("empty-state", open.emptyState, 'data-empty-state="shown"', false);
|
|
472
|
+
problems.push(...webRowProblems(measurement, host, tolerance));
|
|
342
473
|
|
|
343
474
|
return problems;
|
|
344
475
|
}
|
|
345
476
|
|
|
477
|
+
/**
|
|
478
|
+
* The web app's state attributes for its top and bottom rows: `data-primary="collapsed"` only with
|
|
479
|
+
* a visible primary action, and `data-zoom-row="raised"` exactly when the zoom panel at
|
|
480
|
+
* `bottom: 12px` would keep less than 8px to the legal line (`zoomRowRaised`, the shell's rule).
|
|
481
|
+
*/
|
|
482
|
+
export function webRowProblems(
|
|
483
|
+
measurement: ChromeMeasurement,
|
|
484
|
+
host: ShellHost,
|
|
485
|
+
tolerance: number = GEOMETRY_TOLERANCE_PX,
|
|
486
|
+
): string[] {
|
|
487
|
+
const { container, islands, open } = measurement;
|
|
488
|
+
const problems: string[] = [];
|
|
489
|
+
if (host !== "web") {
|
|
490
|
+
if (open.primaryCollapsed)
|
|
491
|
+
problems.push('primary: the root carries data-primary="collapsed" outside the web app');
|
|
492
|
+
if (open.zoomRaised) problems.push('zoom: the root carries data-zoom-row="raised" outside the web app');
|
|
493
|
+
return problems;
|
|
494
|
+
}
|
|
495
|
+
if (open.primaryCollapsed && !islands.primary && !open.emptyState) {
|
|
496
|
+
problems.push('primary: the root carries data-primary="collapsed", but no primary action is visible');
|
|
497
|
+
}
|
|
498
|
+
const legal = islands.legal;
|
|
499
|
+
const zoom = islands.zoom;
|
|
500
|
+
if (legal && zoom) {
|
|
501
|
+
const legalRight = legal.left + legal.width;
|
|
502
|
+
const gap = container.width - SHELL.edge - zoom.width - legalRight;
|
|
503
|
+
const raised = zoomRowRaised({ containerWidth: container.width, legalRight, zoomWidth: zoom.width });
|
|
504
|
+
// A gap within the tolerance of the 8px limit may go either way.
|
|
505
|
+
const borderline = Math.abs(gap - SHELL.gap) <= tolerance;
|
|
506
|
+
if (raised && !open.zoomRaised && !borderline) {
|
|
507
|
+
problems.push(
|
|
508
|
+
`zoom: ${round(gap)}px between the legal line and the zoom panel, but the root lacks data-zoom-row="raised" (below ${SHELL.gap}px the zoom panel stands above the legal line)`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
if (!raised && open.zoomRaised && !borderline) {
|
|
512
|
+
problems.push(
|
|
513
|
+
`zoom: the root carries data-zoom-row="raised" although the zoom panel keeps ${round(gap)}px to the legal line (at least ${SHELL.gap}px)`,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return problems;
|
|
518
|
+
}
|
|
519
|
+
|
|
346
520
|
/**
|
|
347
521
|
* The fit insets of guide §5.9 for what was measured, recomputed from the guide's table: top 88
|
|
348
|
-
* (second row 140), sides 24, bottom 64 (web app with the pill
|
|
522
|
+
* (second row 140), sides 24, bottom 64 (web app with the pill or the zoom panel raised above the
|
|
523
|
+
* legal line 92); right 12 + 288 + 24 with a side
|
|
349
524
|
* inspector; left 12 + legend width + 24 with an open legend in a container wider than 440px; with
|
|
350
525
|
* a bottom sheet the bottom is the distance to its top edge plus 12, and the legend does not count.
|
|
351
526
|
*/
|
|
@@ -354,7 +529,10 @@ export function expectedFitInsets(measurement: ChromeMeasurement, host: ShellHos
|
|
|
354
529
|
const insets: Insets = {
|
|
355
530
|
top: paletteRow === "second-row" ? SHELL.fitInsetTopSecondRow : SHELL.fitInsetTop,
|
|
356
531
|
right: SHELL.fitInsetSide,
|
|
357
|
-
bottom:
|
|
532
|
+
bottom:
|
|
533
|
+
host === "web" && (open.problems || open.zoomRaised)
|
|
534
|
+
? SHELL.fitInsetBottomWeb + legalLineHeight(islands.legal?.height) - LEGAL_LINE
|
|
535
|
+
: SHELL.fitInsetBottom,
|
|
358
536
|
left: SHELL.fitInsetSide,
|
|
359
537
|
};
|
|
360
538
|
if (sheetOpen(measurement)) {
|
|
@@ -423,8 +601,9 @@ export type TooltipRow = "top" | "second-row" | "bottom";
|
|
|
423
601
|
|
|
424
602
|
/**
|
|
425
603
|
* Where the chrome tooltip of a control stands (guide §5.6): 8px below the top row (`top: 60px`)
|
|
426
|
-
* or the second row (`top: 112px`), 8px above the bottom row (`bottom: 60px
|
|
427
|
-
*
|
|
604
|
+
* or the second row (`top: 112px`), 8px above the bottom row (`bottom: 60px`, above the web app's
|
|
605
|
+
* raised zoom row `bottom: 88px`: give its `bottomRow` of 40); centred on its control and shifted
|
|
606
|
+
* to keep 12px to the sides.
|
|
428
607
|
*/
|
|
429
608
|
export function tooltipProblems(
|
|
430
609
|
tooltip: IslandBox,
|
|
@@ -432,13 +611,14 @@ export function tooltipProblems(
|
|
|
432
611
|
row: TooltipRow,
|
|
433
612
|
container: Size,
|
|
434
613
|
tolerance = GEOMETRY_TOLERANCE_PX,
|
|
614
|
+
bottomRow: number = SHELL.edge,
|
|
435
615
|
): string[] {
|
|
436
616
|
const { edge, gap, row: rowHeight, free } = SHELL;
|
|
437
617
|
const clamp = (centred: number) =>
|
|
438
618
|
Math.max(edge, Math.min(centred, container.width - edge - tooltip.width));
|
|
439
619
|
const expectedAt = (left: number): ExpectedBox =>
|
|
440
620
|
row === "bottom"
|
|
441
|
-
? { left, bottom:
|
|
621
|
+
? { left, bottom: bottomRow + rowHeight + gap }
|
|
442
622
|
: { left, top: (row === "top" ? edge : free) + rowHeight + gap };
|
|
443
623
|
let mismatches = boxMismatches(
|
|
444
624
|
"tooltip",
|