@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 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. These captures
133
- complement interaction checks for scrolling, state retention, keyboard navigation
134
- and touch readouts.
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` takes the share from the theme's --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 * (sep === "/" ? Number(amount) / 100 : 1);
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(pair.bg, page) : page;
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.27",
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
  }
@@ -14,7 +14,7 @@ export type Check = {
14
14
  export type Paint = string;
15
15
  export type Pair = {
16
16
  fg: Paint;
17
- bg: Paint;
17
+ bg: Paint | Paint[];
18
18
  min: number;
19
19
  scheme?: Scheme;
20
20
  surface?: "background" | "card";