@siyahi/tools 0.1.9-next.16 → 0.1.9-next.18

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
@@ -8,6 +8,10 @@ Kept apart from the UI package so installing the UI does not pull in Playwright.
8
8
  An [oxlint](https://oxc.rs) JS plugin, `siyahi`. Every rule runs only in files
9
9
  that import `@siyahi/ui`.
10
10
 
11
+ ```sh
12
+ npm install --save-dev @siyahi/tools@next oxlint
13
+ ```
14
+
11
15
  | Rule | Rejects |
12
16
  | ----------------------------- | ------------------------------------------------------------------------------------------------------------------- |
13
17
  | `siyahi/no-color-literal` | hex, `rgb()`, `hsl()`, `oklch()` and other colour literals in strings |
@@ -21,7 +25,14 @@ that import `@siyahi/ui`.
21
25
  {
22
26
  "jsPlugins": ["@siyahi/tools/lint"],
23
27
  "settings": { "shadcn": { "ui": "@siyahi/ui", "themeImports": ["^@siyahi/ui/"] } },
24
- "rules": { "siyahi/no-color-literal": "error" }
28
+ "rules": {
29
+ "siyahi/no-color-literal": "error",
30
+ "siyahi/no-scheme-variant": "error",
31
+ "siyahi/no-numeric-font-size": "error",
32
+ "siyahi/no-chart-theme-map": "error",
33
+ "siyahi/no-raw-colors": "error",
34
+ "siyahi/no-arbitrary-values": "error"
35
+ }
25
36
  }
26
37
  ```
27
38
 
@@ -45,6 +56,12 @@ export default defineConfig({ plugins: [react(), tailwindcss(), preloadFonts()]
45
56
  It runs in `vite build` only. The fonts stay self-hosted: the tags point at
46
57
  the files Vite emitted from `@siyahi/ui`'s fontsource imports.
47
58
 
59
+ The theme's fallback fonts match Geist's metrics until it loads: Arial or its
60
+ Linux equivalents for body text, Courier New for mono. If an app renders its
61
+ own HTML, add preload tags for the build's hashed URLs of
62
+ `geist-latin-wght-normal.woff2`, `geist-latin-ext-wght-normal.woff2` and
63
+ `geist-mono-latin-wght-normal.woff2`.
64
+
48
65
  ## siyahi-cls
49
66
 
50
67
  ```sh
@@ -70,7 +87,7 @@ counted (`moved=` against `in-view=`) and listed, but do not fail it. Run `npx p
70
87
  ## siyahi-shots
71
88
 
72
89
  ```sh
73
- npx siyahi-shots http://localhost:5173/#/page [more URLs] [--out shots] [--gutter 16] [--font Geist] [--chrome '[data-slot="sidebar"]'] [--wait-for h1] [--report]
90
+ 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]
74
91
  ```
75
92
 
76
93
  Renders each URL in Chromium at 1920 and 390 pixels wide, light and dark, and
@@ -100,6 +117,12 @@ anything inside `--chrome` (by default the shadcn sidebar, which sits flush to
100
117
  the edge by design). The font default, Geist, is Siyahi's; pass another family
101
118
  for other pages. Run `npx playwright install chromium` once.
102
119
 
120
+ Pass `--widths 320,390,768,1366,1920` to include small phones, tablets and laptops;
121
+ each width runs in light and dark. The check also rejects a scrolling table whose
122
+ frozen columns occupy more than half its available width. These captures
123
+ complement interaction checks for scrolling, state retention, keyboard navigation
124
+ and touch readouts.
125
+
103
126
  ## siyahi-contrast
104
127
 
105
128
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siyahi/tools",
3
- "version": "0.1.9-next.16",
3
+ "version": "0.1.9-next.18",
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
@@ -37,15 +37,7 @@ import { parseArgs } from "node:util";
37
37
  import { chromium, errors } from "playwright";
38
38
 
39
39
  /** @typedef {{ name: string, width: number, height: number, dark: boolean }} Mode */
40
- /** @typedef {{ gutterLeft: number | null, gutterRight: number | null, overflow: number, overflowWithScrollbar: number, clipped: string[], font: string, fontLoaded: boolean, docHeight: number, h1: string | null }} Probe */
41
-
42
- /** @type {Mode[]} */
43
- const MODES = [
44
- { name: "1920-light", width: 1920, height: 1080, dark: false },
45
- { name: "1920-dark", width: 1920, height: 1080, dark: true },
46
- { name: "390-light", width: 390, height: 844, dark: false },
47
- { name: "390-dark", width: 390, height: 844, dark: true },
48
- ];
40
+ /** @typedef {{ gutterLeft: number | null, gutterRight: number | null, overflow: number, overflowWithScrollbar: number, clipped: string[], frozen: string[], font: string, fontLoaded: boolean, docHeight: number, h1: string | null }} Probe */
49
41
  // How long --wait-for may take to appear before the mode fails.
50
42
  const WAIT_FOR_TIMEOUT = 15_000;
51
43
  // Tallest PNG written. Headless Chromium renders taller pages, but many image
@@ -61,15 +53,30 @@ const { values, positionals } = parseArgs({
61
53
  chrome: { type: "string", default: '[data-slot="sidebar"]' },
62
54
  "wait-for": { type: "string", default: "h1" },
63
55
  report: { type: "boolean", default: false },
56
+ widths: { type: "string", default: "1920,390" },
64
57
  },
65
58
  });
66
59
  if (positionals.length === 0) {
67
60
  console.error(
68
- "usage: siyahi-shots <url>... [--out shots] [--gutter 16] [--font Geist] [--chrome <selector>] [--wait-for <selector>] [--report]",
61
+ "usage: siyahi-shots <url>... [--out shots] [--gutter 16] [--font Geist] [--chrome <selector>] [--wait-for <selector>] [--report] [--widths 1920,390]",
69
62
  );
70
63
  process.exit(2);
71
64
  }
72
65
  const outDir = path.resolve(values.out);
66
+ const widths = values.widths.split(",").map(Number);
67
+ if (widths.some((width) => !Number.isInteger(width) || width < 240 || width > 3840)) {
68
+ console.error("--widths must be comma-separated pixel widths between 240 and 3840");
69
+ process.exit(2);
70
+ }
71
+ /** @type {Mode[]} */
72
+ const MODES = [...new Set(widths)].flatMap((width) =>
73
+ [false, true].map((dark) => ({
74
+ name: `${width}-${dark ? "dark" : "light"}`,
75
+ width,
76
+ height: width < 768 ? 844 : 1080,
77
+ dark,
78
+ })),
79
+ );
73
80
  const minGutter = Number(values.gutter);
74
81
  const waitFor = values["wait-for"];
75
82
  mkdirSync(outDir, { recursive: true });
@@ -93,7 +100,12 @@ const PROBE = `((chrome) => {
93
100
  };
94
101
  const shown = (el) => {
95
102
  const r = el.getBoundingClientRect();
96
- return r.width > 0 && r.height > 0 && !el.closest(chrome) && getComputedStyle(el).visibility !== "hidden";
103
+ const style = getComputedStyle(el);
104
+ // Base UI keeps clipped controls for form submission; aria-hidden alone
105
+ // does not mean a control (or its descendants) is visually hidden.
106
+ const hiddenControl = el.matches('input[aria-hidden="true"],select[aria-hidden="true"],textarea[aria-hidden="true"]') &&
107
+ (style.clipPath === "inset(50%)" || style.clip === "rect(0px, 0px, 0px, 0px)");
108
+ return r.width > 0 && r.height > 0 && !el.closest(chrome) && style.visibility !== "hidden" && !hiddenControl;
97
109
  };
98
110
  // Pixels of el cut off at the side by an ancestor that hides its overflow,
99
111
  // and how it hides it, up to the nearest one that scrolls, which shows the
@@ -129,6 +141,18 @@ const PROBE = `((chrome) => {
129
141
  const overflowWithScrollbar = de.scrollWidth - de.clientWidth;
130
142
  de.style.overflowY = prev;
131
143
  const font = getComputedStyle(document.body).fontFamily;
144
+ const frozen = [];
145
+ for (const table of document.querySelectorAll("table")) {
146
+ let container = table.parentElement;
147
+ while (container && !["auto", "scroll"].includes(getComputedStyle(container).overflowX)) container = container.parentElement;
148
+ if (!container || container.scrollWidth <= container.clientWidth) continue;
149
+ const cells = [...(table.querySelector("tbody tr")?.children ?? [])];
150
+ const pinned = cells.filter((cell) => getComputedStyle(cell).position === "sticky");
151
+ const occupied = pinned.reduce((sum, cell) => sum + cell.getBoundingClientRect().width, 0);
152
+ if (pinned.length < cells.length && occupied > container.clientWidth / 2) {
153
+ frozen.push("frozen columns occupy " + Math.round(occupied) + " of " + container.clientWidth + "px; unpin them in narrow space");
154
+ }
155
+ }
132
156
  const first = font.split(",")[0].trim().replace(/^["']|["']$/g, "");
133
157
  const h1 = document.querySelector("h1");
134
158
  return {
@@ -137,6 +161,7 @@ const PROBE = `((chrome) => {
137
161
  overflow,
138
162
  overflowWithScrollbar,
139
163
  clipped,
164
+ frozen,
140
165
  font: first,
141
166
  fontLoaded: document.fonts.check('16px "' + first + '"') && [...document.fonts].some((f) => f.family.replace(/["']/g, "") === first && f.status === "loaded"),
142
167
  docHeight: de.scrollHeight,
@@ -219,6 +244,7 @@ function problemsOf(p, ready) {
219
244
  if (p.overflowWithScrollbar > 0)
220
245
  problems.push(`scrolls sideways by ${p.overflowWithScrollbar}px once a scrollbar takes its width`);
221
246
  for (const what of p.clipped.slice(0, 5)) problems.push(what);
247
+ problems.push(...p.frozen);
222
248
  if (p.clipped.length > 5) problems.push(`and ${p.clipped.length - 5} more cut off`);
223
249
  if (!p.font.startsWith(values.font)) problems.push(`body font is ${p.font}, not ${values.font}`);
224
250
  else if (!p.fontLoaded && !values.report) problems.push(`${p.font} is named but never loaded`);
package/types/shots.d.mts CHANGED
@@ -11,6 +11,7 @@ export type Probe = {
11
11
  overflow: number;
12
12
  overflowWithScrollbar: number;
13
13
  clipped: string[];
14
+ frozen: string[];
14
15
  font: string;
15
16
  fontLoaded: boolean;
16
17
  docHeight: number;