@siyahi/tools 0.1.9-next.27 → 0.1.9-next.29
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/README.md +28 -4
- package/contrast.mjs +31 -7
- package/package.json +1 -1
- package/shots.mjs +46 -7
- package/types/contrast.d.mts +1 -1
package/README.md
CHANGED
|
@@ -97,7 +97,7 @@ counted (`moved=` against `in-view=`) and listed, but do not fail it. Run `npx p
|
|
|
97
97
|
## siyahi-shots
|
|
98
98
|
|
|
99
99
|
```sh
|
|
100
|
-
npx siyahi-shots http://localhost:5173/#/page [more URLs] [--out shots] [--gutter 16] [--font Geist] [--chrome '[data-slot="sidebar"]'] [--wait-for h1] [--report] [--widths 1920,390]
|
|
100
|
+
npx siyahi-shots http://localhost:5173/#/page [more URLs] [--out shots] [--gutter 16] [--font Geist] [--chrome '[data-slot="sidebar"]'] [--wait-for h1] [--report] [--widths 1920,390] [--script probe.mjs]
|
|
101
101
|
```
|
|
102
102
|
|
|
103
103
|
Renders each URL in Chromium at 1920 and 390 pixels wide, light and dark, and
|
|
@@ -129,9 +129,33 @@ for other pages. Run `npx playwright install chromium` once.
|
|
|
129
129
|
|
|
130
130
|
Pass `--widths 320,390,768,1366,1920` to include small phones, tablets and laptops;
|
|
131
131
|
each width runs in light and dark. The check also rejects a scrolling table whose
|
|
132
|
-
frozen columns occupy more than half its available width.
|
|
133
|
-
|
|
134
|
-
and
|
|
132
|
+
frozen columns occupy more than half its available width.
|
|
133
|
+
|
|
134
|
+
A page error or a console error, such as a failed request, fails the mode and is
|
|
135
|
+
printed under it.
|
|
136
|
+
|
|
137
|
+
To check a control, hover or tooltip, pass `--script` a module whose default
|
|
138
|
+
export takes Playwright's `page` and the mode (`{ name, width, height, dark }`).
|
|
139
|
+
It runs in every mode once the page is ready, at the mode's screen size; whatever
|
|
140
|
+
it returns is printed as `data=<JSON>` under the mode, and the checks measure the
|
|
141
|
+
page as the script left it. The full-page shot is taken after the viewport grows
|
|
142
|
+
to the page's height, which can undo a hover or close a tooltip, so take the
|
|
143
|
+
picture of an interaction inside the script, in the state it made. A locator's
|
|
144
|
+
`screenshot` gives just the card:
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
// hover.mjs: the tooltip of a chart's first bar, as text and as a picture
|
|
148
|
+
export default async (page, mode) => {
|
|
149
|
+
const card = page.locator('[data-slot="card"]', { hasText: "Revenue by channel" });
|
|
150
|
+
await card.locator(".recharts-bar-rectangle").first().hover();
|
|
151
|
+
await card.screenshot({ path: `shots/hover-${mode.name}.png` });
|
|
152
|
+
return (await card.locator(".recharts-tooltip-wrapper").innerText()).split("\n");
|
|
153
|
+
};
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
```sh
|
|
157
|
+
npx siyahi-shots http://localhost:4173/#/data/bar-chart --script hover.mjs
|
|
158
|
+
```
|
|
135
159
|
|
|
136
160
|
## siyahi-contrast
|
|
137
161
|
|
package/contrast.mjs
CHANGED
|
@@ -23,13 +23,14 @@ import { readTheme } from "./theme.mjs";
|
|
|
23
23
|
* What a component paints, written as Tailwind writes it:
|
|
24
24
|
* `muted` is the token, `muted/50` the token at 50% over what lies under it,
|
|
25
25
|
* and `positive@60` is `color-mix(in oklab, var(--positive) 60%, var(--card))`,
|
|
26
|
-
* a treemap tile; `positive@heat-5`
|
|
26
|
+
* a treemap tile; `positive@heat-5` and `negative/wash` take the share from the
|
|
27
|
+
* theme's --heat-5 or --wash.
|
|
27
28
|
* @typedef {string} Paint
|
|
28
29
|
*/
|
|
29
30
|
/**
|
|
30
31
|
* `surface` limits a pair to the canvas or the card, for a paint only one of them ever holds, such as a chart card's
|
|
31
|
-
* hover band.
|
|
32
|
-
* @typedef {{ fg: Paint, bg: Paint, min: number, scheme?: Scheme, surface?: "background" | "card" }} Pair
|
|
32
|
+
* hover band. A list of paints for `bg` lays each over the one before, such as a footer band over an exception's wash.
|
|
33
|
+
* @typedef {{ fg: Paint, bg: Paint | Paint[], min: number, scheme?: Scheme, surface?: "background" | "card" }} Pair
|
|
33
34
|
*/
|
|
34
35
|
|
|
35
36
|
const TEXT = 4.5;
|
|
@@ -103,6 +104,29 @@ const PAIRS = [
|
|
|
103
104
|
// A StatusHeatmap's degraded and critical days against a healthy one, its
|
|
104
105
|
// default 30% grey, so a bad day stands out of a run of good ones.
|
|
105
106
|
...["warning", "destructive"].map((fg) => ({ fg, bg: "muted-foreground/30", min: MARK })),
|
|
107
|
+
// An exception the page marks, washed in its tone at the theme's --wash. A
|
|
108
|
+
// stat tile mixes the tone into the card, opaque (`@wash`); a table row lays
|
|
109
|
+
// it over what is under it (`/wash`). On either: text, muted lines, a Delta
|
|
110
|
+
// icon and --chart-1 (a tile's Sparkline, a row's DataBar; a run of gains or
|
|
111
|
+
// losses takes --positive or --negative, as the SectionCards rule says). A
|
|
112
|
+
// tile's footer band sits over its wash, and a StatusBadge's dot on its own
|
|
113
|
+
// wash over a row's.
|
|
114
|
+
...["negative@wash", "negative/wash", "warning@wash", "warning/wash"].flatMap((wash) => [
|
|
115
|
+
{ fg: "foreground", bg: wash, min: TEXT },
|
|
116
|
+
{ fg: "muted-foreground", bg: wash, min: TEXT },
|
|
117
|
+
{ fg: "positive", bg: wash, min: MARK },
|
|
118
|
+
{ fg: "negative", bg: wash, min: MARK },
|
|
119
|
+
{ fg: "chart-1", bg: wash, min: MARK },
|
|
120
|
+
]),
|
|
121
|
+
...["negative", "warning"].map((tone) => ({
|
|
122
|
+
fg: "muted-foreground",
|
|
123
|
+
bg: [`${tone}@wash`, "muted/50"],
|
|
124
|
+
min: TEXT,
|
|
125
|
+
})),
|
|
126
|
+
...[
|
|
127
|
+
["negative", ["negative/wash", "negative/8"]],
|
|
128
|
+
["warning", ["warning/wash", "warning/10"]],
|
|
129
|
+
].map(([fg, bg]) => ({ fg: String(fg), bg: /** @type {Paint[]} */ (bg), min: MARK })),
|
|
106
130
|
// The selected tab's fill against its list.
|
|
107
131
|
{ fg: "foreground", bg: "muted", min: MARK },
|
|
108
132
|
// Chart marks on the canvas and the card. A dimmed bar keeps its full colour
|
|
@@ -151,13 +175,13 @@ export function checkContrast(theme) {
|
|
|
151
175
|
const paintOver = (paint, under) => {
|
|
152
176
|
const [, name = "", sep, amount = ""] = /^([\w-]+)(?:([/@])([\w.-]+))?$/.exec(paint) ?? [];
|
|
153
177
|
const base = token(name);
|
|
178
|
+
// A share is a number, or the name of a theme token holding a percentage, such as heat-5.
|
|
179
|
+
const share = /^[\d.]+$/.test(amount) ? Number(amount) : sep ? percentage(amount) : 100;
|
|
154
180
|
if (sep === "@") {
|
|
155
|
-
// A share is a number, or the name of a theme token holding a percentage, such as heat-5.
|
|
156
|
-
const share = /^[\d.]+$/.test(amount) ? Number(amount) : percentage(amount);
|
|
157
181
|
const rgb = oklchToSrgb(mixOklab(base, token("card"), share / 100));
|
|
158
182
|
return { rgb, name: `--${name} ${share}% in --card` };
|
|
159
183
|
}
|
|
160
|
-
const alpha = base.alpha *
|
|
184
|
+
const alpha = (base.alpha * share) / 100;
|
|
161
185
|
if (alpha === 1) return { rgb: oklchToSrgb(base), name: `--${paint}` };
|
|
162
186
|
return { rgb: over({ ...base, alpha }, under.rgb), name: `--${paint} over ${under.name}` };
|
|
163
187
|
};
|
|
@@ -166,7 +190,7 @@ export function checkContrast(theme) {
|
|
|
166
190
|
for (const surface of SURFACES) {
|
|
167
191
|
if (pair.surface && pair.surface !== surface) continue;
|
|
168
192
|
const page = { rgb: oklchToSrgb(token(surface)), name: `--${surface}` };
|
|
169
|
-
const bg = pair.bg ? paintOver(
|
|
193
|
+
const bg = [pair.bg].flat().reduce((under, paint) => (paint ? paintOver(paint, under) : under), page);
|
|
170
194
|
const fg = paintOver(pair.fg, bg);
|
|
171
195
|
const [fgHex, bgHex] = [srgbHex(fg.rgb), srgbHex(bg.rgb)];
|
|
172
196
|
const ratio = contrastSrgb(fg.rgb, bg.rgb);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@siyahi/tools",
|
|
3
|
-
"version": "0.1.9-next.
|
|
3
|
+
"version": "0.1.9-next.29",
|
|
4
4
|
"description": "Checks and build helpers for Siyahi pages: a lint plugin, a font preload plugin, a theme contrast check, and screenshot and layout shift scripts.",
|
|
5
5
|
"homepage": "https://siyahi.jatinbansal.com",
|
|
6
6
|
"license": "MIT",
|
package/shots.mjs
CHANGED
|
@@ -7,6 +7,16 @@
|
|
|
7
7
|
//
|
|
8
8
|
// siyahi-shots <url>... [--out shots] [--gutter 16] [--font Geist]
|
|
9
9
|
// [--chrome '[data-slot="sidebar"]'] [--wait-for h1] [--report]
|
|
10
|
+
// [--widths 1920,390] [--script probe.mjs]
|
|
11
|
+
//
|
|
12
|
+
// --script names a module whose default export, async (page, mode) => data,
|
|
13
|
+
// runs in every mode once the page is ready, at the mode's screen size: it
|
|
14
|
+
// clicks, hovers or types with Playwright's page, and whatever it returns is
|
|
15
|
+
// printed as JSON. The checks then measure the page as the script left it. The
|
|
16
|
+
// full-page shot comes after the viewport grows to the page's height, which can
|
|
17
|
+
// undo a hover, so a script takes its own picture of an interaction, in the
|
|
18
|
+
// state it made: await card.screenshot({ path: `hover-${mode.name}.png` }).
|
|
19
|
+
// A page error or console error fails the mode, with or without a script.
|
|
10
20
|
//
|
|
11
21
|
// Measures only once --wait-for (by default the page's h1) is visible, two
|
|
12
22
|
// animation frames have passed and the fonts are ready: a page that renders
|
|
@@ -33,6 +43,7 @@
|
|
|
33
43
|
import { mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
34
44
|
import path from "node:path";
|
|
35
45
|
import process from "node:process";
|
|
46
|
+
import { pathToFileURL } from "node:url";
|
|
36
47
|
import { parseArgs } from "node:util";
|
|
37
48
|
import { chromium, errors } from "playwright";
|
|
38
49
|
|
|
@@ -54,11 +65,12 @@ const { values, positionals } = parseArgs({
|
|
|
54
65
|
"wait-for": { type: "string", default: "h1" },
|
|
55
66
|
report: { type: "boolean", default: false },
|
|
56
67
|
widths: { type: "string", default: "1920,390" },
|
|
68
|
+
script: { type: "string" },
|
|
57
69
|
},
|
|
58
70
|
});
|
|
59
71
|
if (positionals.length === 0) {
|
|
60
72
|
console.error(
|
|
61
|
-
"usage: siyahi-shots <url>... [--out shots] [--gutter 16] [--font Geist] [--chrome <selector>] [--wait-for <selector>] [--report] [--widths 1920,390]",
|
|
73
|
+
"usage: siyahi-shots <url>... [--out shots] [--gutter 16] [--font Geist] [--chrome <selector>] [--wait-for <selector>] [--report] [--widths 1920,390] [--script probe.mjs]",
|
|
62
74
|
);
|
|
63
75
|
process.exit(2);
|
|
64
76
|
}
|
|
@@ -78,6 +90,23 @@ const MODES = [...new Set(widths)].flatMap((width) =>
|
|
|
78
90
|
})),
|
|
79
91
|
);
|
|
80
92
|
const minGutter = Number(values.gutter);
|
|
93
|
+
/**
|
|
94
|
+
* Loads --script's default export, or exits 2 before any browser starts, as for any other bad argument.
|
|
95
|
+
* @param {string} file
|
|
96
|
+
* @returns {Promise<(page: import("playwright").Page, mode: Mode) => Promise<unknown>>}
|
|
97
|
+
*/
|
|
98
|
+
async function loadScript(file) {
|
|
99
|
+
const module = await import(pathToFileURL(path.resolve(file)).href).catch((/** @type {Error} */ error) => {
|
|
100
|
+
console.error(`--script ${file} did not load: ${error.message}`);
|
|
101
|
+
process.exit(2);
|
|
102
|
+
});
|
|
103
|
+
if (typeof module.default !== "function") {
|
|
104
|
+
console.error(`--script ${file} must export a default async function (page, mode)`);
|
|
105
|
+
process.exit(2);
|
|
106
|
+
}
|
|
107
|
+
return module.default;
|
|
108
|
+
}
|
|
109
|
+
const act = values.script ? await loadScript(values.script) : undefined;
|
|
81
110
|
const waitFor = values["wait-for"];
|
|
82
111
|
mkdirSync(outDir, { recursive: true });
|
|
83
112
|
|
|
@@ -199,7 +228,7 @@ function clearShots(base) {
|
|
|
199
228
|
* @param {import("playwright").Browser} browser
|
|
200
229
|
* @param {string} url
|
|
201
230
|
* @param {Mode} mode
|
|
202
|
-
* @returns {Promise<{ probe: Probe, ready: boolean, height: number, files: string[] }>}
|
|
231
|
+
* @returns {Promise<{ probe: Probe, ready: boolean, height: number, files: string[], data: unknown, browserErrors: string[] }>}
|
|
203
232
|
*/
|
|
204
233
|
async function shoot(browser, url, mode) {
|
|
205
234
|
const context = await browser.newContext({
|
|
@@ -207,6 +236,12 @@ async function shoot(browser, url, mode) {
|
|
|
207
236
|
colorScheme: mode.dark ? "dark" : "light",
|
|
208
237
|
});
|
|
209
238
|
const page = await context.newPage();
|
|
239
|
+
/** @type {string[]} */
|
|
240
|
+
const browserErrors = [];
|
|
241
|
+
page.on("pageerror", (error) => browserErrors.push(`page error: ${error.message}`));
|
|
242
|
+
page.on("console", (message) => {
|
|
243
|
+
if (message.type() === "error") browserErrors.push(`console error: ${message.text()} (${message.location().url})`);
|
|
244
|
+
});
|
|
210
245
|
await page.goto(url, { waitUntil: "networkidle" });
|
|
211
246
|
const ready = await page.waitForSelector(waitFor, { timeout: WAIT_FOR_TIMEOUT }).then(
|
|
212
247
|
() => true,
|
|
@@ -216,6 +251,8 @@ async function shoot(browser, url, mode) {
|
|
|
216
251
|
},
|
|
217
252
|
);
|
|
218
253
|
await page.evaluate(SETTLE);
|
|
254
|
+
const data = act && (await act(page, mode));
|
|
255
|
+
if (act) await page.evaluate(SETTLE);
|
|
219
256
|
/** @type {Probe} */
|
|
220
257
|
const probe = await page.evaluate(PROBE);
|
|
221
258
|
const height = Math.max(probe.docHeight, mode.height);
|
|
@@ -234,16 +271,17 @@ async function shoot(browser, url, mode) {
|
|
|
234
271
|
files.push(file);
|
|
235
272
|
}
|
|
236
273
|
await context.close();
|
|
237
|
-
return { probe, ready, height, files };
|
|
274
|
+
return { probe, ready, height, files, data, browserErrors };
|
|
238
275
|
}
|
|
239
276
|
|
|
240
277
|
/**
|
|
241
278
|
* @param {Probe} p
|
|
242
279
|
* @param {boolean} ready whether --wait-for appeared
|
|
280
|
+
* @param {string[]} browserErrors
|
|
243
281
|
* @returns {string[]}
|
|
244
282
|
*/
|
|
245
|
-
function problemsOf(p, ready) {
|
|
246
|
-
const problems = [];
|
|
283
|
+
function problemsOf(p, ready, browserErrors) {
|
|
284
|
+
const problems = [...browserErrors];
|
|
247
285
|
if (!ready) problems.push(`nothing matching ${waitFor} appeared within ${WAIT_FOR_TIMEOUT / 1000}s (--wait-for)`);
|
|
248
286
|
if (p.gutterLeft !== null && p.gutterLeft < minGutter)
|
|
249
287
|
problems.push(`left gutter is ${p.gutterLeft}px, under ${minGutter}px`);
|
|
@@ -264,8 +302,8 @@ const browser = await chromium.launch();
|
|
|
264
302
|
let failed = false;
|
|
265
303
|
for (const url of positionals) {
|
|
266
304
|
for (const mode of MODES) {
|
|
267
|
-
const { probe, ready, height, files } = await shoot(browser, url, mode);
|
|
268
|
-
const problems = problemsOf(probe, ready);
|
|
305
|
+
const { probe, ready, height, files, data, browserErrors } = await shoot(browser, url, mode);
|
|
306
|
+
const problems = problemsOf(probe, ready, browserErrors);
|
|
269
307
|
failed ||= problems.length > 0;
|
|
270
308
|
const font = values.report && !probe.fontLoaded ? `${probe.font} (report, system fallback allowed)` : probe.font;
|
|
271
309
|
console.log(
|
|
@@ -273,6 +311,7 @@ for (const url of positionals) {
|
|
|
273
311
|
`overflow=${probe.overflow}/${probe.overflowWithScrollbar} clipped=${probe.clipped.length} font=${font} h1=${JSON.stringify(probe.h1)} ` +
|
|
274
312
|
`height=${height} ${files.join(" ")}`,
|
|
275
313
|
);
|
|
314
|
+
if (data !== undefined) console.log(` data=${JSON.stringify(data)}`);
|
|
276
315
|
for (const problem of problems) console.log(` ${problem}`);
|
|
277
316
|
}
|
|
278
317
|
}
|