staysfixed 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/CHANGELOG.md +1 -1
- package/README.md +34 -11
- package/package.json +1 -1
- package/src/cli/check.js +71 -4
- package/src/cli/index.js +109 -6
- package/src/cli/init.js +3 -1
- package/src/cli/walk.js +66 -3
- package/src/core/events.js +171 -0
- package/src/core/paths.js +8 -1
- package/src/drive/page.js +60 -4
- package/src/freeze/fonts.js +131 -41
- package/src/freeze/settle.js +177 -2
- package/src/guard/run.js +41 -2
- package/src/picture/capture.js +59 -5
- package/src/picture/compare.js +60 -0
- package/src/picture/run.js +163 -26
- package/src/picture/store.js +65 -0
- package/src/report/console.js +80 -1
- package/src/run.js +229 -39
- package/src/types.js +68 -0
- package/src/walk/run.js +76 -0
- package/src/watch/index.js +286 -0
- package/src/watch/panel.js +1678 -0
- package/src/watch/place.js +279 -0
- package/src/watch/window.js +1242 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the two windows go.
|
|
3
|
+
*
|
|
4
|
+
* The panel is meant to read as part of the app it is checking, not as a second
|
|
5
|
+
* program that happened to open next to it: the app goes hard against one edge
|
|
6
|
+
* of the screen and the panel sits flush against it, no seam, tops lined up. One
|
|
7
|
+
* shape, two windows.
|
|
8
|
+
*
|
|
9
|
+
* Every bit of that is arithmetic, so it lives here on its own, with no browser
|
|
10
|
+
* and no protocol in it. `src/watch/window.js` does the moving; this file only
|
|
11
|
+
* says where. That split is what lets the placement be checked in an ordinary
|
|
12
|
+
* Node process instead of by opening two windows and looking at them.
|
|
13
|
+
*
|
|
14
|
+
* All numbers are screen CSS pixels, and every rectangle that comes out of here
|
|
15
|
+
* is whole pixels — a window manager rounds them anyway, and a half pixel is how
|
|
16
|
+
* you end up with a one-pixel line of wallpaper between two windows that are
|
|
17
|
+
* supposed to be touching.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A rectangle on the screen.
|
|
22
|
+
*
|
|
23
|
+
* `left`/`top` and `x`/`y` are the same two numbers under both of the names a
|
|
24
|
+
* window position goes by — the debugging protocol says left and top, most of
|
|
25
|
+
* the rest of the world says x and y — so whichever one a caller already speaks,
|
|
26
|
+
* it can read this without a conversion step in the middle. Everything built
|
|
27
|
+
* here carries both; anything handed in only has to carry one.
|
|
28
|
+
*
|
|
29
|
+
* @typedef {object} Bounds
|
|
30
|
+
* @property {number} left
|
|
31
|
+
* @property {number} top
|
|
32
|
+
* @property {number} width
|
|
33
|
+
* @property {number} height
|
|
34
|
+
* @property {number} [x]
|
|
35
|
+
* @property {number} [y]
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What the placement needs to know.
|
|
40
|
+
*
|
|
41
|
+
* `screen` is the usable area — the work area, menu bar and dock already taken
|
|
42
|
+
* off — not the whole display. `appSize` is null for a check with no window of
|
|
43
|
+
* its own to sit beside (a headless web check), and `side` names the screen edge
|
|
44
|
+
* the app is pinned to: 'right' puts the app at the far right and the panel on
|
|
45
|
+
* its left, which is the arrangement this was built for.
|
|
46
|
+
*
|
|
47
|
+
* @typedef {object} PlacementInput
|
|
48
|
+
* @property {Bounds} screen
|
|
49
|
+
* @property {{width: number, height: number}|null} [appSize] The app's window size.
|
|
50
|
+
* @property {{width: number, height: number}|null} [app] The same thing, under the shorter name.
|
|
51
|
+
* @property {number} [panelWidth]
|
|
52
|
+
* @property {number} [width] The same thing, under the shorter name.
|
|
53
|
+
* @property {'right'|'left'} [side]
|
|
54
|
+
* @property {number} [gap] Pixels between the two windows. 0 — flush — unless somebody asks otherwise.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @typedef {object} Placement
|
|
59
|
+
* @property {Bounds|null} app Where the app's window should go. Null when there is no app window.
|
|
60
|
+
* @property {Bounds} panel Where the panel should go.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/** Narrower than this and the panel cannot show a picture and a list at once. */
|
|
64
|
+
export const PANEL_MIN_WIDTH = 240;
|
|
65
|
+
|
|
66
|
+
/** Wider than this and it stops being a side panel. */
|
|
67
|
+
export const PANEL_MAX_WIDTH = 900;
|
|
68
|
+
|
|
69
|
+
/** How wide the panel is when nobody says otherwise. Legible down to 420. */
|
|
70
|
+
export const PANEL_DEFAULT_WIDTH = 460;
|
|
71
|
+
|
|
72
|
+
/** Something has to be assumed when a window cannot say how big the screen is. */
|
|
73
|
+
const FALLBACK_SCREEN = { left: 0, top: 0, width: 1440, height: 900 };
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {unknown} value
|
|
77
|
+
* @param {number} fallback
|
|
78
|
+
* @returns {number}
|
|
79
|
+
*/
|
|
80
|
+
function whole(value, fallback) {
|
|
81
|
+
const n = Number(value);
|
|
82
|
+
return Number.isFinite(n) ? Math.round(n) : fallback;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {number} value
|
|
87
|
+
* @param {number} low
|
|
88
|
+
* @param {number} high
|
|
89
|
+
* @returns {number}
|
|
90
|
+
*/
|
|
91
|
+
function clamp(value, low, high) {
|
|
92
|
+
return Math.min(high, Math.max(low, value));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A rectangle under both of its names, in whole pixels.
|
|
97
|
+
* @param {number} left
|
|
98
|
+
* @param {number} top
|
|
99
|
+
* @param {number} width
|
|
100
|
+
* @param {number} height
|
|
101
|
+
* @returns {Bounds}
|
|
102
|
+
*/
|
|
103
|
+
function rect(left, top, width, height) {
|
|
104
|
+
const l = Math.round(left);
|
|
105
|
+
const t = Math.round(top);
|
|
106
|
+
const w = Math.max(1, Math.round(width));
|
|
107
|
+
const h = Math.max(1, Math.round(height));
|
|
108
|
+
return { left: l, top: t, width: w, height: h, x: l, y: t };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A screen we can do arithmetic with, whatever we were handed.
|
|
113
|
+
* @param {Bounds|undefined|null} screen
|
|
114
|
+
* @returns {Bounds}
|
|
115
|
+
*/
|
|
116
|
+
function readScreenRect(screen) {
|
|
117
|
+
const width = Math.max(1, whole(screen?.width, FALLBACK_SCREEN.width));
|
|
118
|
+
const height = Math.max(1, whole(screen?.height, FALLBACK_SCREEN.height));
|
|
119
|
+
return { left: whole(screen?.left, 0), top: whole(screen?.top, 0), width, height };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A window size, or nothing.
|
|
124
|
+
* @param {{width?: number, height?: number}|null|undefined} size
|
|
125
|
+
* @returns {{width: number, height: number}|null}
|
|
126
|
+
*/
|
|
127
|
+
function readSize(size) {
|
|
128
|
+
if (!size || typeof size !== 'object') return null;
|
|
129
|
+
const width = whole(size.width, 0);
|
|
130
|
+
const height = whole(size.height, 0);
|
|
131
|
+
if (width <= 0 || height <= 0) return null;
|
|
132
|
+
return { width, height };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The panel's width, kept sane and kept on the screen.
|
|
137
|
+
* @param {unknown} asked
|
|
138
|
+
* @param {number} screenWidth
|
|
139
|
+
* @returns {number}
|
|
140
|
+
*/
|
|
141
|
+
function panelWidthFor(asked, screenWidth) {
|
|
142
|
+
const wanted = clamp(whole(asked, PANEL_DEFAULT_WIDTH), PANEL_MIN_WIDTH, PANEL_MAX_WIDTH);
|
|
143
|
+
// On a screen narrower than the panel, the screen wins. A window can be pushed
|
|
144
|
+
// off the edge; a window with a negative width cannot exist.
|
|
145
|
+
return Math.max(1, Math.min(wanted, screenWidth));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Work out where both windows go.
|
|
150
|
+
*
|
|
151
|
+
* When the two of them do not fit side by side, the panel keeps its width and
|
|
152
|
+
* its edge and the app is pushed off the far edge of the screen. That is
|
|
153
|
+
* deliberate and it is the one rule here worth remembering: **the app being
|
|
154
|
+
* photographed is never resized.** Its window size is part of what the pictures
|
|
155
|
+
* are of, so shrinking it to make room would quietly change every screen in the
|
|
156
|
+
* run — which is the one thing this tool exists to notice.
|
|
157
|
+
*
|
|
158
|
+
* @param {PlacementInput} input
|
|
159
|
+
* @returns {Placement}
|
|
160
|
+
*/
|
|
161
|
+
export function planPlacement(input) {
|
|
162
|
+
const screen = readScreenRect(input?.screen);
|
|
163
|
+
const side = input?.side === 'left' ? 'left' : 'right';
|
|
164
|
+
const gap = Math.max(0, whole(input?.gap, 0));
|
|
165
|
+
const panelWidth = panelWidthFor(input?.panelWidth ?? input?.width, screen.width);
|
|
166
|
+
const app = readSize(input?.appSize ?? input?.app);
|
|
167
|
+
const screenRight = screen.left + screen.width;
|
|
168
|
+
|
|
169
|
+
// Nothing to sit beside: the panel goes hard against the chosen edge and is as
|
|
170
|
+
// tall as the screen. A headless web check looks like this.
|
|
171
|
+
if (!app) {
|
|
172
|
+
const left = side === 'right' ? screenRight - panelWidth : screen.left;
|
|
173
|
+
return { app: null, panel: rect(left, screen.top, panelWidth, screen.height) };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// When they do not both fit, the PANEL gives way — never the app.
|
|
177
|
+
//
|
|
178
|
+
// Resizing the thing being photographed would change every picture, so that is out.
|
|
179
|
+
// The first version pushed the app off the far edge instead, and on a real 1800px
|
|
180
|
+
// screen with a 1440px app that put a 460px panel straight on top of the app's left
|
|
181
|
+
// third: two windows fighting over the same pixels, which is worse than either.
|
|
182
|
+
// So the panel takes whatever room is left beside the app and narrows into it. It
|
|
183
|
+
// only goes back to pushing the app when the leftover strip is too narrow to read.
|
|
184
|
+
const room = screen.width - app.width - gap;
|
|
185
|
+
const width = room >= panelWidth ? panelWidth : Math.max(PANEL_MIN_WIDTH, room);
|
|
186
|
+
const fits = app.width + gap + width <= screen.width;
|
|
187
|
+
|
|
188
|
+
let appLeft;
|
|
189
|
+
let panelLeft;
|
|
190
|
+
if (side === 'right') {
|
|
191
|
+
if (fits) {
|
|
192
|
+
appLeft = screenRight - app.width;
|
|
193
|
+
panelLeft = appLeft - gap - width;
|
|
194
|
+
} else {
|
|
195
|
+
// Even a minimum-width panel does not fit beside it. The panel takes the far
|
|
196
|
+
// edge and the app starts where the panel ends, running off the other side.
|
|
197
|
+
panelLeft = screen.left;
|
|
198
|
+
appLeft = screen.left + width + gap;
|
|
199
|
+
}
|
|
200
|
+
} else if (fits) {
|
|
201
|
+
appLeft = screen.left;
|
|
202
|
+
panelLeft = appLeft + app.width + gap;
|
|
203
|
+
} else {
|
|
204
|
+
panelLeft = screenRight - width;
|
|
205
|
+
appLeft = panelLeft - gap - app.width;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
app: rect(appLeft, screen.top, app.width, app.height),
|
|
210
|
+
// The panel is as tall as the app, so the two of them read as one window —
|
|
211
|
+
// but never taller than the screen it has to stand on.
|
|
212
|
+
panel: rect(panelLeft, screen.top, width, Math.min(app.height, screen.height)),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The panel flush against an app that is where it is.
|
|
218
|
+
*
|
|
219
|
+
* Not every app can be moved — a desktop app usually cannot (see `window.js`) —
|
|
220
|
+
* and an app nobody can move is not a reason to give up on the arrangement. The
|
|
221
|
+
* panel goes against whichever side of the app there is room for, matching its
|
|
222
|
+
* top and its height, because matching those is most of what makes two windows
|
|
223
|
+
* read as one.
|
|
224
|
+
*
|
|
225
|
+
* @param {Bounds} appWindow Where the app's window actually is.
|
|
226
|
+
* @param {Bounds} screen
|
|
227
|
+
* @param {number} panelWidth
|
|
228
|
+
* @param {'right'|'left'} [side] 'right' means the app is meant to be the right of the pair.
|
|
229
|
+
* @returns {Bounds}
|
|
230
|
+
*/
|
|
231
|
+
export function panelBeside(appWindow, screen, panelWidth, side) {
|
|
232
|
+
const area = readScreenRect(screen);
|
|
233
|
+
const width = panelWidthFor(panelWidth, area.width);
|
|
234
|
+
const app = {
|
|
235
|
+
left: whole(appWindow?.left, area.left),
|
|
236
|
+
top: whole(appWindow?.top, area.top),
|
|
237
|
+
width: Math.max(1, whole(appWindow?.width, 1)),
|
|
238
|
+
height: Math.max(1, whole(appWindow?.height, area.height)),
|
|
239
|
+
};
|
|
240
|
+
const screenRight = area.left + area.width;
|
|
241
|
+
|
|
242
|
+
const onLeft = app.left - width;
|
|
243
|
+
const onRight = app.left + app.width;
|
|
244
|
+
const wanted = side === 'left' ? onRight : onLeft;
|
|
245
|
+
const other = side === 'left' ? onLeft : onRight;
|
|
246
|
+
/** @param {number} left */
|
|
247
|
+
const roomFor = (left) => left >= area.left && left + width <= screenRight;
|
|
248
|
+
|
|
249
|
+
const left = roomFor(wanted)
|
|
250
|
+
? wanted
|
|
251
|
+
: roomFor(other)
|
|
252
|
+
? other
|
|
253
|
+
: // No room on either side of it. Stay on the screen; a panel nobody can
|
|
254
|
+
// see is worse than a panel that overlaps.
|
|
255
|
+
clamp(wanted, area.left, screenRight - width);
|
|
256
|
+
|
|
257
|
+
const top = Math.max(area.top, app.top);
|
|
258
|
+
return rect(left, top, width, Math.min(app.height, area.top + area.height - top));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Is there room for the app and the panel side by side on this screen?
|
|
263
|
+
*
|
|
264
|
+
* Worth asking before promising somebody a side-by-side view: when the answer is
|
|
265
|
+
* no, `planPlacement` still returns something usable, but part of the app ends
|
|
266
|
+
* up off the edge of the screen.
|
|
267
|
+
*
|
|
268
|
+
* @param {Bounds} screen
|
|
269
|
+
* @param {{width: number, height: number}|null} appSize
|
|
270
|
+
* @param {number} panelWidth
|
|
271
|
+
* @returns {boolean}
|
|
272
|
+
*/
|
|
273
|
+
export function fitsAlongside(screen, appSize, panelWidth) {
|
|
274
|
+
const area = readScreenRect(screen);
|
|
275
|
+
const width = panelWidthFor(panelWidth, area.width);
|
|
276
|
+
const app = readSize(appSize);
|
|
277
|
+
if (!app) return width <= area.width;
|
|
278
|
+
return app.width + width <= area.width;
|
|
279
|
+
}
|