@miragon/modeler-shell 0.0.0-stage → 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 +2869 -0
- package/LICENSE +21 -0
- package/README.md +566 -2
- package/THIRD_PARTY_NOTICES.md +52 -0
- package/dist/appearance-BBaM_4bD.d.ts +323 -0
- package/dist/appearance-D3FUcbu6.d.cts +53 -0
- package/dist/appearance-D3FUcbu6.d.ts +53 -0
- package/dist/appearance-XX3JC3pI.d.cts +323 -0
- package/dist/chunk-66HCSJNY.js +2792 -0
- package/dist/chunk-66HCSJNY.js.map +1 -0
- package/dist/chunk-KYDG5DMX.js +136 -0
- package/dist/chunk-KYDG5DMX.js.map +1 -0
- package/dist/chunk-N4QXFF3A.js +23 -0
- package/dist/chunk-N4QXFF3A.js.map +1 -0
- package/dist/chunk-OADW3GPA.js +785 -0
- package/dist/chunk-OADW3GPA.js.map +1 -0
- package/dist/core.cjs +873 -0
- package/dist/core.cjs.map +1 -0
- package/dist/core.d.cts +246 -0
- package/dist/core.d.ts +246 -0
- package/dist/core.js +131 -0
- package/dist/core.js.map +1 -0
- package/dist/diagram-js.cjs +1147 -0
- package/dist/diagram-js.cjs.map +1 -0
- package/dist/diagram-js.d.cts +170 -0
- package/dist/diagram-js.d.ts +170 -0
- package/dist/diagram-js.js +879 -0
- package/dist/diagram-js.js.map +1 -0
- package/dist/index.cjs +3632 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +29 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +143 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol-DhYBakWL.d.cts +15 -0
- package/dist/protocol-IeW8bf4f.d.ts +15 -0
- package/dist/shell.css +1603 -0
- package/dist/testing-vscode-stub.cjs +332 -0
- package/dist/testing-vscode-stub.cjs.map +1 -0
- package/dist/testing-vscode-stub.d.cts +65 -0
- package/dist/testing-vscode-stub.d.ts +65 -0
- package/dist/testing-vscode-stub.js +154 -0
- package/dist/testing-vscode-stub.js.map +1 -0
- package/dist/testing.cjs +4663 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +231 -0
- package/dist/testing.d.ts +231 -0
- package/dist/testing.js +4483 -0
- package/dist/testing.js.map +1 -0
- package/dist/toasts-Bp0REqdq.d.cts +142 -0
- package/dist/toasts-Bp0REqdq.d.ts +142 -0
- package/dist/types-CnRrINRs.d.cts +445 -0
- package/dist/types-PuDZczzy.d.ts +445 -0
- package/dist/vscode-extension.cjs +215 -0
- package/dist/vscode-extension.cjs.map +1 -0
- package/dist/vscode-extension.d.cts +103 -0
- package/dist/vscode-extension.d.ts +103 -0
- package/dist/vscode-extension.js +24 -0
- package/dist/vscode-extension.js.map +1 -0
- package/dist/vscode-webview.cjs +3561 -0
- package/dist/vscode-webview.cjs.map +1 -0
- package/dist/vscode-webview.d.cts +84 -0
- package/dist/vscode-webview.d.ts +84 -0
- package/dist/vscode-webview.js +259 -0
- package/dist/vscode-webview.js.map +1 -0
- package/package.json +145 -4
- package/src/core/appearance.ts +104 -0
- 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 +220 -0
- package/src/core/index.ts +127 -0
- package/src/core/keymap.ts +295 -0
- package/src/core/layout.ts +69 -0
- package/src/core/messages.ts +159 -0
- package/src/core/sizes.ts +85 -0
- package/src/core/types.ts +106 -0
- 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/core/zoom.ts +74 -0
- package/src/core.ts +2 -0
- package/src/diagram-js/connect.ts +75 -0
- package/src/diagram-js/index.ts +16 -0
- package/src/diagram-js/module.ts +20 -0
- package/src/diagram-js/palette-skin.ts +267 -0
- package/src/diagram-js/palette.css +269 -0
- package/src/diagram-js/service.ts +243 -0
- package/src/diagram-js/types.ts +67 -0
- package/src/diagram-js/viewport.ts +583 -0
- package/src/diagram-js.ts +2 -0
- package/src/dom/buttons.css +124 -0
- package/src/dom/element.ts +51 -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/hint.ts +51 -0
- package/src/dom/icons.ts +309 -0
- package/src/dom/index.ts +42 -0
- package/src/dom/inspector.ts +35 -0
- package/src/dom/keys.ts +67 -0
- package/src/dom/ledger.ts +87 -0
- package/src/dom/legend.ts +85 -0
- package/src/dom/menu.ts +404 -0
- package/src/dom/mount.ts +731 -0
- package/src/dom/problems.ts +73 -0
- package/src/dom/shell-dom.css +969 -0
- package/src/dom/status.ts +72 -0
- package/src/dom/tooltip.ts +237 -0
- package/src/dom/types.ts +459 -0
- 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/dom/zoom-panel.ts +154 -0
- package/src/index.ts +3 -0
- package/src/shell.css +9 -0
- package/src/testing/case-tools.ts +164 -0
- package/src/testing/cases.ts +1077 -0
- package/src/testing/checks.ts +868 -0
- package/src/testing/facts.ts +641 -0
- package/src/testing/geometry.ts +652 -0
- package/src/testing/harness.ts +187 -0
- package/src/testing/index.ts +19 -0
- package/src/testing/page-tools.ts +457 -0
- package/src/testing/page.ts +27 -0
- package/src/testing/vscode-stub.ts +237 -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/testing-vscode-stub.ts +2 -0
- package/src/testing.ts +2 -0
- package/src/vscode/extension/html.ts +107 -0
- package/src/vscode/extension/index.ts +8 -0
- package/src/vscode/extension/reveal.ts +9 -0
- package/src/vscode/extension/theme-bridge.ts +85 -0
- package/src/vscode/extension/types.ts +36 -0
- package/src/vscode/protocol.ts +31 -0
- package/src/vscode/webview/appearance.ts +141 -0
- package/src/vscode/webview/index.ts +8 -0
- package/src/vscode/webview/mount.ts +88 -0
- package/src/vscode/webview/state.ts +116 -0
- package/src/vscode-extension.ts +2 -0
- package/src/vscode-webview.ts +2 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CHROME_ISLANDS,
|
|
3
|
+
LEGAL_LINE_HEIGHT,
|
|
4
|
+
PALETTE_CLEARANCE,
|
|
5
|
+
SHELL,
|
|
6
|
+
legalLineHeight,
|
|
7
|
+
toastStackBottom,
|
|
8
|
+
toastStackWidth,
|
|
9
|
+
zoomRowRaised,
|
|
10
|
+
type BottomObstacle,
|
|
11
|
+
type ChromeIsland,
|
|
12
|
+
type Insets,
|
|
13
|
+
type PaletteRow,
|
|
14
|
+
type ShellHost,
|
|
15
|
+
type Size,
|
|
16
|
+
} from "../core/index.js";
|
|
17
|
+
import type { PageLike } from "./page.js";
|
|
18
|
+
import { inPage, type RelativeBox, type ToolWindow } from "./page-tools.js";
|
|
19
|
+
|
|
20
|
+
/** Every island box must match within this many pixels (guide §13.4). */
|
|
21
|
+
export const GEOMETRY_TOLERANCE_PX = 1;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `getBoundingClientRect()` relative to the shell root's padding box, where its absolutely
|
|
25
|
+
* positioned islands anchor (a border on the root, as in a framed foreign page, does not count).
|
|
26
|
+
*/
|
|
27
|
+
export interface IslandBox {
|
|
28
|
+
left: number;
|
|
29
|
+
top: number;
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ChromeMeasurement {
|
|
35
|
+
container: Size;
|
|
36
|
+
/** From the root's `data-palette` attribute. */
|
|
37
|
+
paletteRow: PaletteRow;
|
|
38
|
+
/** The visible islands, found by `[data-chrome]`. */
|
|
39
|
+
islands: Partial<Record<ChromeIsland, IslandBox>>;
|
|
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;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Constraints of one island; every given field must hold within `GEOMETRY_TOLERANCE_PX`. */
|
|
58
|
+
export interface ExpectedBox {
|
|
59
|
+
left?: number;
|
|
60
|
+
top?: number;
|
|
61
|
+
right?: number;
|
|
62
|
+
bottom?: number;
|
|
63
|
+
width?: number;
|
|
64
|
+
height?: number;
|
|
65
|
+
/** Horizontal centre relative to the root. */
|
|
66
|
+
centerX?: number;
|
|
67
|
+
/** Vertical centre relative to the root (the web app's empty-state card). */
|
|
68
|
+
centerY?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const DEFAULT_ROOT = ".cd-shell";
|
|
72
|
+
|
|
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. */
|
|
74
|
+
export const ZOOM_PANEL_WIDTH = 173;
|
|
75
|
+
|
|
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;
|
|
85
|
+
|
|
86
|
+
/** Minimum width of the legend card (guide §5.10). */
|
|
87
|
+
const LEGEND_MIN_WIDTH = 184;
|
|
88
|
+
|
|
89
|
+
const STATUS_MAX_WIDTH = 560;
|
|
90
|
+
const DROPDOWN_MAX_WIDTH = 320;
|
|
91
|
+
const TOOLTIP_MAX_WIDTH = 280;
|
|
92
|
+
const SHEET_MAX_HEIGHT_SHARE = 0.45;
|
|
93
|
+
|
|
94
|
+
type MeasureResult = { error: string } | (ChromeMeasurement & { duplicates: string[] });
|
|
95
|
+
|
|
96
|
+
const measureProbe = ({ root, islands }: { root: string; islands: readonly string[] }): MeasureResult => {
|
|
97
|
+
const tools = (window as ToolWindow).__modelerShellConformance!;
|
|
98
|
+
const rootElement = document.querySelector<HTMLElement>(root);
|
|
99
|
+
if (!rootElement) return { error: `no element matches the shell root selector "${root}"` };
|
|
100
|
+
const boxes: Partial<Record<string, RelativeBox>> = {};
|
|
101
|
+
const duplicates: string[] = [];
|
|
102
|
+
for (const element of rootElement.querySelectorAll("[data-chrome]")) {
|
|
103
|
+
const name = element.getAttribute("data-chrome") ?? "";
|
|
104
|
+
if (!islands.includes(name) || !tools.visible(element)) continue;
|
|
105
|
+
if (boxes[name]) {
|
|
106
|
+
duplicates.push(name);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
boxes[name] = tools.box(element, rootElement);
|
|
110
|
+
}
|
|
111
|
+
const cardWidths = [...rootElement.querySelectorAll<HTMLElement>('[data-chrome="toasts"] > [data-status]')]
|
|
112
|
+
.filter((card) => tools.visible(card))
|
|
113
|
+
.map((card) => card.getBoundingClientRect().width);
|
|
114
|
+
return {
|
|
115
|
+
...(cardWidths.length > 0 ? { toastCardWidth: Math.max(...cardWidths) } : {}),
|
|
116
|
+
container: tools.container(rootElement),
|
|
117
|
+
paletteRow: rootElement.getAttribute("data-palette") === "second-row" ? "second-row" : "top",
|
|
118
|
+
islands: boxes,
|
|
119
|
+
open: {
|
|
120
|
+
legend: rootElement.getAttribute("data-legend") === "open",
|
|
121
|
+
inspector: rootElement.getAttribute("data-inspector") === "open",
|
|
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",
|
|
126
|
+
},
|
|
127
|
+
duplicates,
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Measures the islands of the shell under `root` (default `.cd-shell`) in the page. */
|
|
132
|
+
export async function measureChrome(page: PageLike, root: string = DEFAULT_ROOT): Promise<ChromeMeasurement> {
|
|
133
|
+
const result = await inPage(page, measureProbe, { root, islands: CHROME_ISLANDS });
|
|
134
|
+
if ("error" in result) throw new Error(`@miragon/modeler-shell conformance: ${result.error}`);
|
|
135
|
+
if (result.duplicates.length > 0) {
|
|
136
|
+
const names = [...new Set(result.duplicates)].map((name) => `[data-chrome="${name}"]`).join(", ");
|
|
137
|
+
throw new Error(`@miragon/modeler-shell conformance: more than one visible ${names}`);
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
container: result.container,
|
|
141
|
+
paletteRow: result.paletteRow,
|
|
142
|
+
islands: result.islands,
|
|
143
|
+
open: result.open,
|
|
144
|
+
...(result.toastCardWidth !== undefined ? { toastCardWidth: result.toastCardWidth } : {}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Upper and lower limits the guide gives as "at least" and "at most" (guide §5.4 to §5.15). */
|
|
149
|
+
export interface BoxLimits {
|
|
150
|
+
minWidth?: number;
|
|
151
|
+
maxWidth?: number;
|
|
152
|
+
maxHeight?: number;
|
|
153
|
+
minLeft?: number;
|
|
154
|
+
minRight?: number;
|
|
155
|
+
minBottom?: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** What the guide expects of the chrome in a measured state. */
|
|
159
|
+
export interface ChromeExpectations {
|
|
160
|
+
boxes: Partial<Record<ChromeIsland, ExpectedBox>>;
|
|
161
|
+
limits: Partial<Record<ChromeIsland, BoxLimits>>;
|
|
162
|
+
/** Islands that must be visible. */
|
|
163
|
+
required: ChromeIsland[];
|
|
164
|
+
/** Islands that must not be visible, with the rule that hides them. */
|
|
165
|
+
hidden: Partial<Record<ChromeIsland, string>>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isNarrow(container: Size): boolean {
|
|
169
|
+
return container.width <= SHELL.narrow;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isVeryNarrow(container: Size): boolean {
|
|
173
|
+
return container.width <= SHELL.veryNarrow;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The open inspector is a bottom sheet up to `SHELL.narrow` (guide §5.11). */
|
|
177
|
+
function sheetOpen(measurement: ChromeMeasurement): boolean {
|
|
178
|
+
return measurement.open.inspector && isNarrow(measurement.container);
|
|
179
|
+
}
|
|
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
|
+
|
|
206
|
+
/** The anchors and limits of guide §5.2 to §5.15 for `measurement` in `host`. */
|
|
207
|
+
export function chromeExpectations(measurement: ChromeMeasurement, host: ShellHost): ChromeExpectations {
|
|
208
|
+
const { container, islands, open, paletteRow } = measurement;
|
|
209
|
+
const { edge, gap, row, free, inspectorWidth } = SHELL;
|
|
210
|
+
const web = host === "web";
|
|
211
|
+
const startScreen = web && open.emptyState;
|
|
212
|
+
const raised = web && open.zoomRaised;
|
|
213
|
+
const secondRow = paletteRow === "second-row";
|
|
214
|
+
const sheet = sheetOpen(measurement);
|
|
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);
|
|
218
|
+
const boxes: Partial<Record<ChromeIsland, ExpectedBox>> = {};
|
|
219
|
+
const limits: Partial<Record<ChromeIsland, BoxLimits>> = {};
|
|
220
|
+
const hidden: Partial<Record<ChromeIsland, string>> = {};
|
|
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");
|
|
224
|
+
|
|
225
|
+
boxes.menu = { left: edge, top: edge, width: row, height: row };
|
|
226
|
+
|
|
227
|
+
boxes.palette = { top: secondRow ? free : edge, height: row, centerX: container.width / 2 };
|
|
228
|
+
const menu = islands.menu;
|
|
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
|
+
}
|
|
238
|
+
|
|
239
|
+
if (sheet) {
|
|
240
|
+
const reason = "hidden while the inspector is a bottom sheet (guide §5.11)";
|
|
241
|
+
hidden.zoom = reason;
|
|
242
|
+
hidden.problems = reason;
|
|
243
|
+
hidden.legend = reason;
|
|
244
|
+
} else {
|
|
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;
|
|
249
|
+
if (islands.problems) boxes.problems = { left: edge, bottom: stackBase, height: row };
|
|
250
|
+
if (islands.legend) {
|
|
251
|
+
const besidePill = open.problems ? stackBase + row + gap : stackBase;
|
|
252
|
+
const aboveBottomRow = (raised ? legalStack : edge) + row + gap;
|
|
253
|
+
const bottom = isVeryNarrow(container) ? Math.max(besidePill, aboveBottomRow) : besidePill;
|
|
254
|
+
boxes.legend = { left: edge, bottom };
|
|
255
|
+
limits.legend = { minWidth: LEGEND_MIN_WIDTH };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (islands.inspector) {
|
|
260
|
+
if (sheet) {
|
|
261
|
+
boxes.inspector = { left: edge, right: edge, bottom: edge };
|
|
262
|
+
limits.inspector = { maxHeight: container.height * SHEET_MAX_HEIGHT_SHARE };
|
|
263
|
+
} else {
|
|
264
|
+
boxes.inspector = { top: free, right: edge, width: inspectorWidth };
|
|
265
|
+
limits.inspector = { minBottom: free };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (islands.status) {
|
|
270
|
+
const strip = sideInspector ? container.width - edge - inspectorWidth : container.width;
|
|
271
|
+
boxes.status = { top: secondRow ? free + row + edge : free, centerX: strip / 2 };
|
|
272
|
+
limits.status = {
|
|
273
|
+
minLeft: edge,
|
|
274
|
+
maxWidth: Math.min(STATUS_MAX_WIDTH, container.width - 2 * edge),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (web) {
|
|
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 };
|
|
293
|
+
} else {
|
|
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
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return { boxes, limits, required, hidden };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The boxes guide §5.2–§5.12 expects for what was measured (anchors per host and breakpoint). */
|
|
333
|
+
export function expectedChromeBoxes(
|
|
334
|
+
measurement: ChromeMeasurement,
|
|
335
|
+
host: ShellHost,
|
|
336
|
+
): Partial<Record<ChromeIsland, ExpectedBox>> {
|
|
337
|
+
return chromeExpectations(measurement, host).boxes;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const round = (value: number): number => Math.round(value * 10) / 10;
|
|
341
|
+
|
|
342
|
+
function sides(box: IslandBox, container: Size): Required<ExpectedBox> {
|
|
343
|
+
return {
|
|
344
|
+
left: box.left,
|
|
345
|
+
top: box.top,
|
|
346
|
+
right: container.width - box.left - box.width,
|
|
347
|
+
bottom: container.height - box.top - box.height,
|
|
348
|
+
width: box.width,
|
|
349
|
+
height: box.height,
|
|
350
|
+
centerX: box.left + box.width / 2,
|
|
351
|
+
centerY: box.top + box.height / 2,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Every field of `expected` that `box` misses by more than `tolerance`, as readable problems. */
|
|
356
|
+
export function boxMismatches(
|
|
357
|
+
name: string,
|
|
358
|
+
box: IslandBox,
|
|
359
|
+
expected: ExpectedBox,
|
|
360
|
+
container: Size,
|
|
361
|
+
tolerance: number = GEOMETRY_TOLERANCE_PX,
|
|
362
|
+
): string[] {
|
|
363
|
+
const actual = sides(box, container);
|
|
364
|
+
return (Object.keys(expected) as Array<keyof ExpectedBox>).flatMap((field) => {
|
|
365
|
+
const want = expected[field];
|
|
366
|
+
if (want === undefined) return [];
|
|
367
|
+
const have = actual[field];
|
|
368
|
+
return Math.abs(have - want) > tolerance + 1e-6
|
|
369
|
+
? [`${name}: ${field} is ${round(have)}px, expected ${round(want)}px (±${tolerance}px)`]
|
|
370
|
+
: [];
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Every limit `box` breaks by more than `tolerance`. */
|
|
375
|
+
export function limitViolations(
|
|
376
|
+
name: string,
|
|
377
|
+
box: IslandBox,
|
|
378
|
+
limits: BoxLimits,
|
|
379
|
+
container: Size,
|
|
380
|
+
tolerance: number = GEOMETRY_TOLERANCE_PX,
|
|
381
|
+
): string[] {
|
|
382
|
+
const actual = sides(box, container);
|
|
383
|
+
const problems: string[] = [];
|
|
384
|
+
const atLeast = (field: string, have: number, limit: number | undefined) => {
|
|
385
|
+
if (limit !== undefined && have < limit - tolerance - 1e-6) {
|
|
386
|
+
problems.push(`${name}: ${field} is ${round(have)}px, at least ${round(limit)}px expected`);
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
const atMost = (field: string, have: number, limit: number | undefined) => {
|
|
390
|
+
if (limit !== undefined && have > limit + tolerance + 1e-6) {
|
|
391
|
+
problems.push(`${name}: ${field} is ${round(have)}px, at most ${round(limit)}px expected`);
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
atLeast("width", actual.width, limits.minWidth);
|
|
395
|
+
atMost("width", actual.width, limits.maxWidth);
|
|
396
|
+
atMost("height", actual.height, limits.maxHeight);
|
|
397
|
+
atLeast("left", actual.left, limits.minLeft);
|
|
398
|
+
atLeast("right", actual.right, limits.minRight);
|
|
399
|
+
atLeast("bottom", actual.bottom, limits.minBottom);
|
|
400
|
+
return problems;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Every deviation of a measured state from guide §5.2 to §5.15: missing and unexpected islands,
|
|
405
|
+
* boxes and limits, the palette row decision and the root's state attributes.
|
|
406
|
+
*/
|
|
407
|
+
export function geometryProblems(
|
|
408
|
+
measurement: ChromeMeasurement,
|
|
409
|
+
host: ShellHost,
|
|
410
|
+
tolerance: number = GEOMETRY_TOLERANCE_PX,
|
|
411
|
+
): string[] {
|
|
412
|
+
const { container, islands, open, paletteRow } = measurement;
|
|
413
|
+
const expectations = chromeExpectations(measurement, host);
|
|
414
|
+
const problems: string[] = [];
|
|
415
|
+
const sheet = sheetOpen(measurement);
|
|
416
|
+
|
|
417
|
+
for (const name of expectations.required) {
|
|
418
|
+
if (!islands[name]) problems.push(`${name}: [data-chrome="${name}"] is missing or not visible`);
|
|
419
|
+
}
|
|
420
|
+
for (const name of CHROME_ISLANDS) {
|
|
421
|
+
const box = islands[name];
|
|
422
|
+
if (!box) continue;
|
|
423
|
+
const expected = expectations.boxes[name];
|
|
424
|
+
if (!expected) {
|
|
425
|
+
const reason = expectations.hidden[name] ?? "no anchor of the guide applies";
|
|
426
|
+
problems.push(`${name}: visible, but ${reason}`);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
problems.push(...boxMismatches(name, box, expected, container, tolerance));
|
|
430
|
+
const limits = expectations.limits[name];
|
|
431
|
+
if (limits) problems.push(...limitViolations(name, box, limits, container, tolerance));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (isNarrow(container) && paletteRow !== "second-row") {
|
|
435
|
+
problems.push(
|
|
436
|
+
`palette: data-palette="${paletteRow}" in a ${round(container.width)}px wide container; up to ${SHELL.narrow}px it is "second-row" (guide §5.15)`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
const palette = islands.palette;
|
|
440
|
+
const menu = islands.menu;
|
|
441
|
+
const primary = host === "web" ? islands.primary : undefined;
|
|
442
|
+
if (paletteRow === "second-row" && !isNarrow(container) && palette && menu) {
|
|
443
|
+
const clipped = palette.width >= container.width - 2 * SHELL.edge - tolerance;
|
|
444
|
+
const topRowLeft = (container.width - palette.width) / 2;
|
|
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`;
|
|
455
|
+
problems.push(
|
|
456
|
+
`palette: in the second row although the top row keeps ${kept} (at least ${PALETTE_CLEARANCE}px suffice, guide §5.15)`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const state = (island: ChromeIsland, flag: boolean, attribute: string, hiddenBySheet: boolean) => {
|
|
462
|
+
const shown = Boolean(islands[island]);
|
|
463
|
+
if (flag && !shown && !hiddenBySheet) {
|
|
464
|
+
problems.push(`${island}: the root carries ${attribute}, but the island is not visible`);
|
|
465
|
+
}
|
|
466
|
+
if (!flag && shown) problems.push(`${island}: visible, but the root lacks ${attribute}`);
|
|
467
|
+
};
|
|
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));
|
|
473
|
+
|
|
474
|
+
return problems;
|
|
475
|
+
}
|
|
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
|
+
|
|
520
|
+
/**
|
|
521
|
+
* The fit insets of guide §5.9 for what was measured, recomputed from the guide's table: top 88
|
|
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
|
|
524
|
+
* inspector; left 12 + legend width + 24 with an open legend in a container wider than 440px; with
|
|
525
|
+
* a bottom sheet the bottom is the distance to its top edge plus 12, and the legend does not count.
|
|
526
|
+
*/
|
|
527
|
+
export function expectedFitInsets(measurement: ChromeMeasurement, host: ShellHost): Insets {
|
|
528
|
+
const { container, islands, open, paletteRow } = measurement;
|
|
529
|
+
const insets: Insets = {
|
|
530
|
+
top: paletteRow === "second-row" ? SHELL.fitInsetTopSecondRow : SHELL.fitInsetTop,
|
|
531
|
+
right: SHELL.fitInsetSide,
|
|
532
|
+
bottom:
|
|
533
|
+
host === "web" && (open.problems || open.zoomRaised)
|
|
534
|
+
? SHELL.fitInsetBottomWeb + legalLineHeight(islands.legal?.height) - LEGAL_LINE
|
|
535
|
+
: SHELL.fitInsetBottom,
|
|
536
|
+
left: SHELL.fitInsetSide,
|
|
537
|
+
};
|
|
538
|
+
if (sheetOpen(measurement)) {
|
|
539
|
+
const sheet = islands.inspector;
|
|
540
|
+
insets.bottom = sheet ? container.height - sheet.top + SHELL.edge : SHELL.fitInsetBottom;
|
|
541
|
+
return insets;
|
|
542
|
+
}
|
|
543
|
+
if (open.inspector) insets.right = SHELL.edge + SHELL.inspectorWidth + SHELL.fitInsetSide;
|
|
544
|
+
const legend = islands.legend;
|
|
545
|
+
if (open.legend && legend && legend.width > 0 && container.width > SHELL.veryNarrow) {
|
|
546
|
+
insets.left = SHELL.edge + legend.width + SHELL.fitInsetSide;
|
|
547
|
+
}
|
|
548
|
+
return insets;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** A rectangle relative to the shell root's padding box, by its edges. */
|
|
552
|
+
export interface Rectangle {
|
|
553
|
+
left: number;
|
|
554
|
+
top: number;
|
|
555
|
+
right: number;
|
|
556
|
+
bottom: number;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* The free rectangle of guide §5.9: the container minus `expectedFitInsets`. Where the insets
|
|
561
|
+
* leave no room on an axis, that axis spans the whole container (as the fit centres there).
|
|
562
|
+
*/
|
|
563
|
+
export function freeRectangle(measurement: ChromeMeasurement, host: ShellHost): Rectangle {
|
|
564
|
+
const { container } = measurement;
|
|
565
|
+
const insets = expectedFitInsets(measurement, host);
|
|
566
|
+
const horizontal = container.width - insets.left - insets.right > 0;
|
|
567
|
+
const vertical = container.height - insets.top - insets.bottom > 0;
|
|
568
|
+
return {
|
|
569
|
+
left: horizontal ? insets.left : 0,
|
|
570
|
+
top: vertical ? insets.top : 0,
|
|
571
|
+
right: horizontal ? container.width - insets.right : container.width,
|
|
572
|
+
bottom: vertical ? container.height - insets.bottom : container.height,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Where the dropdown opens (guide §5.4): under the trigger, at least 248px, inside the container. */
|
|
577
|
+
export function dropdownProblems(
|
|
578
|
+
box: IslandBox,
|
|
579
|
+
container: Size,
|
|
580
|
+
tolerance = GEOMETRY_TOLERANCE_PX,
|
|
581
|
+
): string[] {
|
|
582
|
+
const { edge, gap, row, menuMinWidth } = SHELL;
|
|
583
|
+
return [
|
|
584
|
+
...boxMismatches("dropdown", box, { top: edge + row + gap, left: edge }, container, tolerance),
|
|
585
|
+
...limitViolations(
|
|
586
|
+
"dropdown",
|
|
587
|
+
box,
|
|
588
|
+
{
|
|
589
|
+
minWidth: menuMinWidth,
|
|
590
|
+
maxWidth: Math.min(DROPDOWN_MAX_WIDTH, container.width - 2 * edge),
|
|
591
|
+
minBottom: edge,
|
|
592
|
+
},
|
|
593
|
+
container,
|
|
594
|
+
tolerance,
|
|
595
|
+
),
|
|
596
|
+
];
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Which island a tooltip belongs to decides its row (guide §5.6). */
|
|
600
|
+
export type TooltipRow = "top" | "second-row" | "bottom";
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Where the chrome tooltip of a control stands (guide §5.6): 8px below the top row (`top: 60px`)
|
|
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.
|
|
607
|
+
*/
|
|
608
|
+
export function tooltipProblems(
|
|
609
|
+
tooltip: IslandBox,
|
|
610
|
+
anchor: IslandBox,
|
|
611
|
+
row: TooltipRow,
|
|
612
|
+
container: Size,
|
|
613
|
+
tolerance = GEOMETRY_TOLERANCE_PX,
|
|
614
|
+
bottomRow: number = SHELL.edge,
|
|
615
|
+
): string[] {
|
|
616
|
+
const { edge, gap, row: rowHeight, free } = SHELL;
|
|
617
|
+
const clamp = (centred: number) =>
|
|
618
|
+
Math.max(edge, Math.min(centred, container.width - edge - tooltip.width));
|
|
619
|
+
const expectedAt = (left: number): ExpectedBox =>
|
|
620
|
+
row === "bottom"
|
|
621
|
+
? { left, bottom: bottomRow + rowHeight + gap }
|
|
622
|
+
: { left, top: (row === "top" ? edge : free) + rowHeight + gap };
|
|
623
|
+
let mismatches = boxMismatches(
|
|
624
|
+
"tooltip",
|
|
625
|
+
tooltip,
|
|
626
|
+
expectedAt(clamp(anchor.left + anchor.width / 2 - tooltip.width / 2)),
|
|
627
|
+
container,
|
|
628
|
+
tolerance,
|
|
629
|
+
);
|
|
630
|
+
// Guide §5.15 reads "mittig im Container" for the second row; until the guide settles whether
|
|
631
|
+
// that means the container or the entry, either centre conforms.
|
|
632
|
+
if (row === "second-row" && mismatches.length > 0) {
|
|
633
|
+
const inContainer = boxMismatches(
|
|
634
|
+
"tooltip",
|
|
635
|
+
tooltip,
|
|
636
|
+
expectedAt(clamp(container.width / 2 - tooltip.width / 2)),
|
|
637
|
+
container,
|
|
638
|
+
tolerance,
|
|
639
|
+
);
|
|
640
|
+
if (inContainer.length === 0) mismatches = inContainer;
|
|
641
|
+
}
|
|
642
|
+
return [
|
|
643
|
+
...mismatches,
|
|
644
|
+
...limitViolations(
|
|
645
|
+
"tooltip",
|
|
646
|
+
tooltip,
|
|
647
|
+
{ minLeft: edge, minRight: edge, maxWidth: Math.min(TOOLTIP_MAX_WIDTH, container.width - 2 * edge) },
|
|
648
|
+
container,
|
|
649
|
+
tolerance,
|
|
650
|
+
),
|
|
651
|
+
];
|
|
652
|
+
}
|