@siyahi/tools 0.1.9-next.3 → 0.1.9-next.31

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,23 +8,44 @@ 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
- | Rule | Rejects |
12
- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------- |
13
- | `siyahi/no-color-literal` | hex, `rgb()`, `hsl()`, `oklch()` and other colour literals in strings |
14
- | `siyahi/no-scheme-variant` | `dark:` and `light:` Tailwind variants |
15
- | `siyahi/no-numeric-font-size` | `fontSize: 13`, `fontSize="12px"` |
16
- | `siyahi/no-chart-theme-map` | a chart config `theme: { light, dark }` map; shadcn's chart applies the dark half only under a `.dark` class |
17
- | `siyahi/no-raw-colors` | Tailwind palette colours such as `bg-pink-500` ([@shadcn/lint](https://github.com/shadcn-ui/lint)) |
18
- | `siyahi/no-arbitrary-values` | arbitrary values such as `text-[13px]`, `w-[37px]`, `bg-[#fff]` ([@shadcn/lint](https://github.com/shadcn-ui/lint)) |
11
+ ```sh
12
+ npm install --save-dev @siyahi/tools@next oxlint
13
+ ```
14
+
15
+ | Rule | Rejects |
16
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
17
+ | `siyahi/no-color-literal` | hex, `rgb()`, `hsl()`, `oklch()` and other colour literals in strings |
18
+ | `siyahi/no-scheme-variant` | `dark:` and `light:` Tailwind variants |
19
+ | `siyahi/no-numeric-font-size` | `fontSize: 13`, `fontSize="12px"` |
20
+ | `siyahi/no-chart-theme-map` | a chart config `theme: { light, dark }` map; shadcn's chart applies the dark half only under a `.dark` class |
21
+ | `siyahi/no-raw-colors` | Tailwind palette colours such as `bg-pink-500` ([@shadcn/lint](https://github.com/shadcn-ui/lint)) |
22
+ | `siyahi/no-arbitrary-values` | arbitrary values such as `text-[13px]`, `w-[37px]`, `bg-[#fff]` ([@shadcn/lint](https://github.com/shadcn-ui/lint)) |
23
+ | `siyahi/no-restyle` | classes that change a component's spacing, colour, shape or type, such as `<CardContent className="gap-4">` ([@shadcn/lint](https://github.com/shadcn-ui/lint)) |
19
24
 
20
25
  ```json
21
26
  {
22
27
  "jsPlugins": ["@siyahi/tools/lint"],
23
28
  "settings": { "shadcn": { "ui": "@siyahi/ui", "themeImports": ["^@siyahi/ui/"] } },
24
- "rules": { "siyahi/no-color-literal": "error" }
29
+ "rules": {
30
+ "siyahi/no-color-literal": "error",
31
+ "siyahi/no-scheme-variant": "error",
32
+ "siyahi/no-numeric-font-size": "error",
33
+ "siyahi/no-chart-theme-map": "error",
34
+ "siyahi/no-raw-colors": "error",
35
+ "siyahi/no-arbitrary-values": "error",
36
+ "siyahi/no-restyle": "error"
37
+ }
25
38
  }
26
39
  ```
27
40
 
41
+ `siyahi/no-restyle` lets a page place a component (margin, width, grid and
42
+ flex placement) and nothing else; `Collapsible` and `CollapsibleContent`, which
43
+ draw nothing of their own, may also take spacing. Put anything else on a plain
44
+ element inside or around the component: `<CardContent><div className="flex
45
+ flex-col gap-4">`, `<TableCell><span className="font-mono">`,
46
+ `<div className="rounded-lg border"><Command>`. Body text is already
47
+ `tabular-nums`, so cells need no class for it.
48
+
28
49
  A Vite+ or ESLint config can spread every rule at once:
29
50
  `import { recommended } from "@siyahi/tools/lint"`.
30
51
 
@@ -45,6 +66,12 @@ export default defineConfig({ plugins: [react(), tailwindcss(), preloadFonts()]
45
66
  It runs in `vite build` only. The fonts stay self-hosted: the tags point at
46
67
  the files Vite emitted from `@siyahi/ui`'s fontsource imports.
47
68
 
69
+ The theme's fallback fonts match Geist's metrics until it loads: Arial or its
70
+ Linux equivalents for body text, Courier New for mono. If an app renders its
71
+ own HTML, add preload tags for the build's hashed URLs of
72
+ `geist-latin-wght-normal.woff2`, `geist-latin-ext-wght-normal.woff2` and
73
+ `geist-mono-latin-wght-normal.woff2`.
74
+
48
75
  ## siyahi-cls
49
76
 
50
77
  ```sh
@@ -70,7 +97,7 @@ counted (`moved=` against `in-view=`) and listed, but do not fail it. Run `npx p
70
97
  ## siyahi-shots
71
98
 
72
99
  ```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]
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]
74
101
  ```
75
102
 
76
103
  Renders each URL in Chromium at 1920 and 390 pixels wide, light and dark, and
@@ -100,6 +127,36 @@ anything inside `--chrome` (by default the shadcn sidebar, which sits flush to
100
127
  the edge by design). The font default, Geist, is Siyahi's; pass another family
101
128
  for other pages. Run `npx playwright install chromium` once.
102
129
 
130
+ Pass `--widths 320,390,768,1366,1920` to include small phones, tablets and laptops;
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
+ 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
+ ```
159
+
103
160
  ## siyahi-contrast
104
161
 
105
162
  ```sh
@@ -108,7 +165,11 @@ npx siyahi-contrast path/to/theme.css
108
165
 
109
166
  Reads the light `:root` block and the `@variant dark { … }` block inside it.
110
167
  Every checked token must be set in both; dark never borrows a light value. It
111
- fails unless text colours reach 4.5:1 on the canvas, the card and their own
112
- fills, chart colours reach 3:1 against the card and against the slots next to
113
- them, and every chart colour sits inside sRGB. Slots two apart share a
114
- lightness and are told apart by hue, so series take slots in order.
168
+ checks each pair on the canvas and on the card, blending a translucent fill over
169
+ the surface under it. It fails unless text reaches 4.5:1 on the page, on its own
170
+ fill and on the tints Siyahi's components put it on (Callout washes, the
171
+ destructive tint and its hover, code-line fills, treemap heat steps), a field's
172
+ edge, the focus ring, a selected tab and every chart and meaning colour reach
173
+ 3:1 against what is next to them, and every colour sits inside sRGB. Slots two
174
+ apart share a lightness and are told apart by hue, so series take slots in
175
+ order.
@@ -8,17 +8,17 @@ file as copied; `packages/ui/registry.lock.json` lists them all and
8
8
 
9
9
  ## Copied into `@siyahi/ui`
10
10
 
11
- | What | Where in the package | Origin | Licence |
12
- | ------------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
13
- | shadcn/ui core components (installed with `shadcn add`, never edited) | `src/components/ui/*`, `src/hooks/use-mobile.ts` | https://github.com/shadcn-ui/ui | MIT, Copyright (c) 2023 shadcn |
14
- | shadcn `tailwind.css` (variants and utilities the components use) | `styles/shadcn.css` | https://github.com/shadcn-ui/ui/tree/main/packages/shadcn | MIT, Copyright (c) 2023 shadcn |
15
- | shadcn block `sidebar-07` | `src/components/registry/sidebar-07/*` | https://ui.shadcn.com/r/styles/base-vega/sidebar-07.json | MIT, Copyright (c) 2023 shadcn |
16
- | shadcn block `dashboard-01`, `SectionCards` only | `src/components/registry/dashboard-01/section-cards.tsx` | https://ui.shadcn.com/r/styles/base-vega/dashboard-01.json | MIT, Copyright (c) 2023 shadcn |
17
- | Heatmaps: calendar, weekday by hour, status timeline | `src/components/registry/heatmap/*` | https://shadcn-heatmap.pages.dev/r/calendar-heatmap.json, https://shadcn-heatmap.pages.dev/r/weekday-heatmap.json, https://shadcn-heatmap.pages.dev/r/status-heatmap.json (source: https://github.com/rutopio/shadcn-heatmap) | MIT, Copyright (c) 2026 ChingRu |
18
- | niko-table data table on TanStack Table | `src/components/registry/niko-table/*` | https://niko-table.com/r/data-table.json, https://niko-table.com/r/data-table-core.json, https://niko-table.com/r/data-table-ui.json, https://niko-table.com/r/data-table-faceted-filter.json, https://niko-table.com/r/data-table-column-sort.json, https://niko-table.com/r/data-table-search-filter.json (source: https://github.com/Semkoo/niko-table-registry) | MIT, Copyright (c) 2025 Semir N.; original work Copyright (c) 2025 WebDevSimplified |
19
- | shadcn chart blocks `chart-bar-multiple`, `chart-area-stacked`, `chart-line-multiple` | `src/components/registry/charts/*` | https://ui.shadcn.com/r/styles/new-york-v4/chart-bar-multiple.json, https://ui.shadcn.com/r/styles/new-york-v4/chart-area-stacked.json, https://ui.shadcn.com/r/styles/new-york-v4/chart-line-multiple.json | MIT, Copyright (c) 2023 shadcn |
20
- | dashboardcn ranked list (`BarList`) and stacked bar (`DistributionBar`) | `src/components/registry/dashboardcn/*` | https://dashboardcn.com/r/bar-list.json, https://dashboardcn.com/r/distribution-bar.json (source: https://github.com/NoahGdev/dashboardcn) | MIT, Copyright (c) 2026 dashboardcn contributors |
21
- | MiniDev copy button (`CopyButton`) | `src/components/registry/minidev/copy-button.tsx` | https://ui.minidev.pro/r/copy-button.json (source: https://github.com/amankaushik481/minidev-ui) | MIT, Copyright (c) 2026 MiniDev |
11
+ | What | Where in the package | Origin | Licence |
12
+ | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
13
+ | shadcn/ui core components (installed with `shadcn add`, never edited) | `src/components/ui/*`, `src/hooks/use-mobile.ts` | https://github.com/shadcn-ui/ui | MIT, Copyright (c) 2023 shadcn |
14
+ | shadcn `tailwind.css` (variants and utilities the components use) | `styles/shadcn.css` | https://github.com/shadcn-ui/ui/tree/main/packages/shadcn | MIT, Copyright (c) 2023 shadcn |
15
+ | shadcn block `sidebar-07` | `src/components/registry/sidebar-07/*` | https://ui.shadcn.com/r/styles/base-vega/sidebar-07.json | MIT, Copyright (c) 2023 shadcn |
16
+ | shadcn block `dashboard-01`, `SectionCards` only | `src/components/registry/dashboard-01/section-cards.tsx` | https://ui.shadcn.com/r/styles/base-vega/dashboard-01.json | MIT, Copyright (c) 2023 shadcn |
17
+ | Heatmaps: calendar, weekday by hour, status timeline | `src/components/registry/heatmap/*` | https://shadcn-heatmap.pages.dev/r/calendar-heatmap.json, https://shadcn-heatmap.pages.dev/r/weekday-heatmap.json, https://shadcn-heatmap.pages.dev/r/status-heatmap.json (source: https://github.com/rutopio/shadcn-heatmap) | MIT, Copyright (c) 2026 ChingRu |
18
+ | niko-table data table on TanStack Table | `src/components/registry/niko-table/*` | https://niko-table.com/r/data-table.json, https://niko-table.com/r/data-table-core.json, https://niko-table.com/r/data-table-ui.json, https://niko-table.com/r/data-table-faceted-filter.json, https://niko-table.com/r/data-table-column-sort.json, https://niko-table.com/r/data-table-search-filter.json (source: https://github.com/Semkoo/niko-table-registry) | MIT, Copyright (c) 2025 Semir N.; original work Copyright (c) 2025 WebDevSimplified |
19
+ | shadcn chart blocks `chart-bar-multiple`, `chart-area-stacked`, `chart-line-multiple` | `src/components/registry/charts/*` | https://ui.shadcn.com/r/styles/new-york-v4/chart-bar-multiple.json, https://ui.shadcn.com/r/styles/new-york-v4/chart-area-stacked.json, https://ui.shadcn.com/r/styles/new-york-v4/chart-line-multiple.json | MIT, Copyright (c) 2023 shadcn |
20
+ | dashboardcn ranked list (`BarList`), stacked bar (`DistributionBar`), `Sparkline` and `MetricStrip` | `src/components/registry/dashboardcn/*` | https://dashboardcn.com/r/bar-list.json, https://dashboardcn.com/r/distribution-bar.json, https://dashboardcn.com/r/sparkline.json, https://dashboardcn.com/r/kpi-row-card.json (source: https://github.com/NoahGdev/dashboardcn) | MIT, Copyright (c) 2026 dashboardcn contributors |
21
+ | MiniDev copy button (`CopyButton`) | `src/components/registry/minidev/copy-button.tsx` | https://ui.minidev.pro/r/copy-button.json (source: https://github.com/amankaushik481/minidev-ui) | MIT, Copyright (c) 2026 MiniDev |
22
22
 
23
23
  ## Runtime dependencies with attribution terms
24
24
 
package/color.mjs CHANGED
@@ -76,6 +76,17 @@ export function luminance([r, g, b]) {
76
76
  return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
77
77
  }
78
78
 
79
+ /**
80
+ * WCAG contrast ratio between two opaque colours, as gamma-encoded sRGB.
81
+ * @param {[number, number, number]} a
82
+ * @param {[number, number, number]} b
83
+ * @returns {number}
84
+ */
85
+ export function contrastSrgb(a, b) {
86
+ const [hi, lo] = [luminance(a), luminance(b)].toSorted((x, y) => y - x);
87
+ return ((hi ?? 0) + 0.05) / ((lo ?? 0) + 0.05);
88
+ }
89
+
79
90
  /**
80
91
  * WCAG contrast ratio between two opaque OKLCH colours.
81
92
  * @param {Oklch} a
@@ -83,8 +94,38 @@ export function luminance([r, g, b]) {
83
94
  * @returns {number}
84
95
  */
85
96
  export function contrast(a, b) {
86
- const [hi, lo] = [luminance(oklchToSrgb(a)), luminance(oklchToSrgb(b))].toSorted((x, y) => y - x);
87
- return ((hi ?? 0) + 0.05) / ((lo ?? 0) + 0.05);
97
+ return contrastSrgb(oklchToSrgb(a), oklchToSrgb(b));
98
+ }
99
+
100
+ /**
101
+ * The colour a translucent colour paints over an opaque one. Browsers blend
102
+ * gamma-encoded sRGB channels, so `bg-muted/50` over the card is half of each.
103
+ * @param {Oklch} top
104
+ * @param {[number, number, number]} under
105
+ * @returns {[number, number, number]}
106
+ */
107
+ export function over(top, [r0, g0, b0]) {
108
+ const [r, g, b] = oklchToSrgb(top);
109
+ const blend = (/** @type {number} */ fg, /** @type {number} */ bg) => top.alpha * fg + (1 - top.alpha) * bg;
110
+ return [blend(r, r0), blend(g, g0), blend(b, b0)];
111
+ }
112
+
113
+ /** @param {Oklch} color @returns {[number, number, number]} */
114
+ const toOklab = ({ l, c, h }) => [l, c * Math.cos((h * Math.PI) / 180), c * Math.sin((h * Math.PI) / 180)];
115
+
116
+ /**
117
+ * `color-mix(in oklab, a share, b)`, with share in 0..1 and opaque inputs.
118
+ * @param {Oklch} a
119
+ * @param {Oklch} b
120
+ * @param {number} share
121
+ * @returns {Oklch}
122
+ */
123
+ export function mixOklab(a, b, share) {
124
+ const [la, xa, ya] = toOklab(a);
125
+ const [lb, xb, yb] = toOklab(b);
126
+ const along = (/** @type {number} */ from, /** @type {number} */ to) => share * from + (1 - share) * to;
127
+ const [x, y] = [along(xa, xb), along(ya, yb)];
128
+ return { l: along(la, lb), c: Math.hypot(x, y), h: (Math.atan2(y, x) * 180) / Math.PI, alpha: 1 };
88
129
  }
89
130
 
90
131
  /**
@@ -93,7 +134,16 @@ export function contrast(a, b) {
93
134
  * @returns {string}
94
135
  */
95
136
  export function toHex(color) {
96
- return `#${oklchToSrgb(color)
137
+ return srgbHex(oklchToSrgb(color));
138
+ }
139
+
140
+ /**
141
+ * Hex form of gamma-encoded sRGB channels in 0..1.
142
+ * @param {[number, number, number]} rgb
143
+ * @returns {string}
144
+ */
145
+ export function srgbHex(rgb) {
146
+ return `#${rgb
97
147
  .map((v) =>
98
148
  Math.round(v * 255)
99
149
  .toString(16)
package/contrast.mjs CHANGED
@@ -1,90 +1,247 @@
1
1
  #!/usr/bin/env node
2
2
  // @ts-check
3
- // Fails when a theme colour is too faint to read: 4.5:1 for text on the
4
- // canvas and the card and for a foreground on its own fill, 3:1 for chart
5
- // colours against the card and against the slot next to them. Slots two apart
6
- // share a lightness and are told apart by hue, so they are not compared. Chart
7
- // colours must also sit inside sRGB, so the browser paints what was scored.
3
+ // Fails when a theme colour is too faint where Siyahi's components paint it:
4
+ // 4.5:1 for text, 3:1 for a mark a reader needs (a control's edge, a focus
5
+ // ring, a selected tab, a chart mark). Each pair is checked on both surfaces a
6
+ // block can sit on, the canvas and the card, with translucent fills blended
7
+ // over that surface as the browser paints them. Chart slots two apart share a
8
+ // lightness and are told apart by hue, so they are not compared. Every colour
9
+ // must also sit inside sRGB, so the browser paints what was scored.
8
10
  //
9
11
  // siyahi-contrast styles/siyahi.css
10
12
  import { readFileSync, realpathSync } from "node:fs";
11
13
  import process from "node:process";
12
14
  import { pathToFileURL } from "node:url";
13
- import { contrast, inSrgb, parseOklch, toHex } from "./color.mjs";
15
+ import { contrastSrgb, inSrgb, mixOklab, oklchToSrgb, over, parseOklch, srgbHex } from "./color.mjs";
14
16
  import { readTheme } from "./theme.mjs";
15
17
 
16
18
  /** @typedef {import("./theme.mjs").Theme} Theme */
17
- /** @typedef {{ scheme: "light" | "dark", fg: string, bg: string, ratio: number, min: number }} Check */
19
+ /** @typedef {import("./color.mjs").Oklch} Oklch */
20
+ /** @typedef {"light" | "dark"} Scheme */
21
+ /** @typedef {{ scheme: Scheme, fg: string, bg: string, fgHex: string, bgHex: string, ratio: number, min: number }} Check */
22
+ /**
23
+ * What a component paints, written as Tailwind writes it:
24
+ * `muted` is the token, `muted/50` the token at 50% over what lies under it,
25
+ * and `positive@60` is `color-mix(in oklab, var(--positive) 60%, var(--card))`,
26
+ * a treemap tile; `positive@heat-5` and `negative/wash` take the share from the
27
+ * theme's --heat-5 or --wash.
28
+ * @typedef {string} Paint
29
+ */
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
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
34
+ */
18
35
 
19
- const TEXT = ["foreground", "muted-foreground", "positive", "negative", "warning", "link", "destructive"];
36
+ const TEXT = 4.5;
37
+ const MARK = 3;
20
38
  const SURFACES = ["background", "card"];
21
- const FILLS = [
22
- ["card-foreground", "card"],
23
- ["popover-foreground", "popover"],
24
- ["primary-foreground", "primary"],
25
- ["secondary-foreground", "secondary"],
26
- ["accent-foreground", "accent"],
27
- ["positive-foreground", "positive"],
28
- ["negative-foreground", "negative"],
29
- ["warning-foreground", "warning"],
30
- ["sidebar-foreground", "sidebar"],
31
- ];
32
39
  const CHARTS = ["chart-1", "chart-2", "chart-3", "chart-4", "chart-5"];
33
40
 
41
+ /**
42
+ * The label on each heat step, both sides, at the shares the theme's --heat-1 to --heat-5 set.
43
+ * @type {Pair[]}
44
+ */
45
+ const HEAT_LABELS = ["positive", "negative"].flatMap((side) =>
46
+ [1, 2, 3, 4, 5].map((step) => ({ fg: "foreground", bg: `${side}@heat-${step}`, min: TEXT })),
47
+ );
48
+
49
+ /** @type {Pair[]} */
50
+ const PAIRS = [
51
+ // Text on the page and on each token's own fill.
52
+ ...["foreground", "muted-foreground", "positive", "negative", "warning", "link", "destructive"].map((fg) => ({
53
+ fg,
54
+ bg: "",
55
+ min: TEXT,
56
+ })),
57
+ ...[
58
+ ["card-foreground", "card"],
59
+ ["popover-foreground", "popover"],
60
+ ["primary-foreground", "primary"],
61
+ ["secondary-foreground", "secondary"],
62
+ ["accent-foreground", "accent"],
63
+ ["positive-foreground", "positive"],
64
+ ["negative-foreground", "negative"],
65
+ ["warning-foreground", "warning"],
66
+ ["sidebar-foreground", "sidebar"],
67
+ ["sidebar-primary-foreground", "sidebar-primary"],
68
+ ["sidebar-accent-foreground", "sidebar-accent"],
69
+ // A selected toggle or tab: the foreground fill with the canvas colour as text.
70
+ ["background", "foreground"],
71
+ ].map(([fg = "", bg = ""]) => ({ fg, bg, min: TEXT })),
72
+ // Muted text on the fills it sits on: avatar initials and highlighted code
73
+ // lines (muted), a neutral Callout, CardFooter and the command field
74
+ // (muted/50), a Quote's source (muted/60), the accent, warning, positive and
75
+ // negative Callouts, StatusAlerts and StatusBadges, a removed code line
76
+ // (destructive/10).
77
+ ...["muted", "muted/50", "muted/60", "chart-1/8", "warning/10", "positive/8", "negative/8", "destructive/10"].map(
78
+ (bg) => ({ fg: "muted-foreground", bg, min: TEXT }),
79
+ ),
80
+ // A destructive button or badge: its tint at rest and on hover, a light
81
+ // menu item's focus, and a dark input's own fill under its text.
82
+ { fg: "destructive", bg: "destructive/10", min: TEXT, scheme: "light" },
83
+ { fg: "destructive", bg: "destructive/20", min: TEXT },
84
+ { fg: "destructive", bg: "destructive/30", min: TEXT, scheme: "dark" },
85
+ { fg: "foreground", bg: "input/30", min: TEXT, scheme: "dark" },
86
+ { fg: "muted-foreground", bg: "input/30", min: TEXT, scheme: "dark" },
87
+ // A field's edge against the page, against its own fill (a slider's muted
88
+ // track, the command field, a dark field), so the box reads as a box. A
89
+ // focus ring and an invalid field's edge recolour that edge and are read
90
+ // against the page.
91
+ ...["", "muted", "muted/50"].map((bg) => ({ fg: "input", bg, min: MARK })),
92
+ { fg: "input", bg: "input/30", min: MARK, scheme: "dark" },
93
+ ...["ring", "destructive"].map((fg) => ({ fg, bg: "", min: MARK })),
94
+ // Focus is a 2px ring of --ring on the control's edge with a 2px ring of the
95
+ // page colour inside it. The outer ring meets what lies around the control:
96
+ // the page and the card (above), a tab list's muted fill and the sidebar.
97
+ // The inner ring meets the control's own fill, which matters where that is
98
+ // dark: a primary button's (a pressed toggle's foreground fill is checked
99
+ // with the selected toggle's text, above).
100
+ ...["muted", "sidebar"].map((bg) => ({ fg: "ring", bg, min: MARK })),
101
+ { fg: "background", bg: "primary", min: MARK },
102
+ // An invalid field's red wash (--field-wash): the value and placeholder on
103
+ // it, and its red edge against it.
104
+ ...["foreground", "muted-foreground"].map((fg) => ({ fg, bg: "destructive@field-wash", min: TEXT })),
105
+ { fg: "destructive", bg: "destructive@field-wash", min: MARK },
106
+ // A tone's icon or StatusBadge dot against the tone's own wash (lib/tone.ts).
107
+ ...[
108
+ ["link", "chart-1/8"],
109
+ ["warning", "warning/10"],
110
+ ["positive", "positive/8"],
111
+ ["negative", "negative/8"],
112
+ ].map(([fg = "", bg = ""]) => ({ fg, bg, min: MARK })),
113
+ // A StatusHeatmap's degraded and critical days against a healthy one, its
114
+ // default 30% grey, so a bad day stands out of a run of good ones.
115
+ ...["warning", "destructive"].map((fg) => ({ fg, bg: "muted-foreground/30", min: MARK })),
116
+ // An exception the page marks, washed in its tone at the theme's --wash. A
117
+ // stat tile mixes the tone into the card, opaque (`@wash`); a table row lays
118
+ // it over what is under it (`/wash`). On either: text, muted lines, a Delta
119
+ // icon and --chart-1 (a tile's Sparkline, a row's DataBar; a run of gains or
120
+ // losses takes --positive or --negative, as the SectionCards rule says). A
121
+ // tile's footer band sits over its wash, and a StatusBadge's dot on its own
122
+ // wash over a row's.
123
+ ...["negative@wash", "negative/wash", "warning@wash", "warning/wash"].flatMap((wash) => [
124
+ { fg: "foreground", bg: wash, min: TEXT },
125
+ { fg: "muted-foreground", bg: wash, min: TEXT },
126
+ { fg: "positive", bg: wash, min: MARK },
127
+ { fg: "negative", bg: wash, min: MARK },
128
+ { fg: "chart-1", bg: wash, min: MARK },
129
+ ]),
130
+ ...["negative", "warning"].map((tone) => ({
131
+ fg: "muted-foreground",
132
+ bg: [`${tone}@wash`, "muted/50"],
133
+ min: TEXT,
134
+ })),
135
+ ...[
136
+ ["negative", ["negative/wash", "negative/8"]],
137
+ ["warning", ["warning/wash", "warning/10"]],
138
+ ].map(([fg, bg]) => ({ fg: String(fg), bg: /** @type {Paint[]} */ (bg), min: MARK })),
139
+ // A chart's forecast stretch is hatched with 1px stripes of the text colour
140
+ // at 16% (lib/chart-marks). Its dashed lines and its FORECAST and TODAY
141
+ // labels sit on card-coloured halos, so no stripe runs beside them; the
142
+ // today rule in the link colour does meet the stripes.
143
+ { fg: "link", bg: "foreground/16", min: MARK, surface: "card" },
144
+ // The selected tab's fill against its list.
145
+ { fg: "foreground", bg: "muted", min: MARK },
146
+ // Chart marks on the canvas and the card. A bar is read by its full-colour
147
+ // edge, so these are the edges too; the tint inside is carried by the edge and
148
+ // has no pair of its own, nor has a dimmed bar's fainter tint. A stale
149
+ // block's marks (greyed, at 65%) are exempt: stale is meant to read faintly.
150
+ ...[...CHARTS, "positive", "negative"].map((fg) => ({ fg, bg: "", min: MARK })),
151
+ ...CHARTS.slice(0, -1).map((fg, i) => ({ fg, bg: CHARTS[i + 1] ?? "", min: MARK })),
152
+ // Every bar colour against the 40% muted band a chart card lays behind a
153
+ // hovered category (lib/chart-frame).
154
+ ...[...CHARTS, "positive", "negative"].map((fg) => ({ fg, bg: "muted/40", min: MARK, surface: "card" })),
155
+ // A chart card's footer text, on the footer's muted wash.
156
+ { fg: "foreground", bg: "muted/50", min: TEXT },
157
+ // Treemap, ChangeGrid and signed-calendar labels on every heat step
158
+ // (lib/heat.ts heatFill), at the theme's own --heat-N shares. A ChangeGrid
159
+ // cell with no change is muted, with its figure in the text colour.
160
+ { fg: "foreground", bg: "muted", min: TEXT },
161
+ ...HEAT_LABELS,
162
+ ];
163
+
34
164
  /**
35
165
  * @param {Theme} theme
36
166
  * @returns {Check[]}
37
167
  */
38
168
  export function checkContrast(theme) {
39
- /** @type {Check[]} */
40
- const checks = [];
169
+ /** @type {Map<string, Check>} */
170
+ const checks = new Map();
41
171
  for (const scheme of /** @type {const} */ (["light", "dark"])) {
42
- const vars = theme[scheme];
43
- const color = (/** @type {string} */ name) => {
44
- const value = vars[name];
172
+ const percentage = (/** @type {string} */ name) => {
173
+ const value = theme[scheme][name];
174
+ const match = value === undefined ? null : /^([\d.]+)%$/.exec(value);
175
+ if (!match) throw new Error(`${scheme}: --${name} is "${value}", not a percentage`);
176
+ return Number(match[1]);
177
+ };
178
+ const token = (/** @type {string} */ name) => {
179
+ const value = theme[scheme][name];
45
180
  if (value === undefined) throw new Error(`${scheme}: --${name} is not set in the ${scheme} block`);
46
181
  const parsed = parseOklch(value);
47
182
  if (!parsed) throw new Error(`${scheme}: --${name} is "${value}", not an oklch() colour`);
48
- if (parsed.alpha < 1) throw new Error(`${scheme}: --${name} is translucent; contrast needs an opaque colour`);
49
183
  return parsed;
50
184
  };
51
- const pair = (/** @type {string} */ fg, /** @type {string} */ bg, /** @type {number} */ min) =>
52
- checks.push({ scheme, fg, bg, ratio: contrast(color(fg), color(bg)), min });
53
- for (const fg of TEXT) for (const bg of SURFACES) pair(fg, bg, 4.5);
54
- for (const [fg, bg] of FILLS) pair(/** @type {string} */ (fg), /** @type {string} */ (bg), 4.5);
55
- CHARTS.forEach((chart, i) => {
56
- pair(chart, "card", 3);
57
- const next = CHARTS[i + 1];
58
- if (next) pair(chart, next, 3);
59
- });
185
+ /**
186
+ * The sRGB a paint shows over what lies under it, and how a message names it.
187
+ * @param {Paint} paint @param {{ rgb: [number, number, number], name: string }} under
188
+ */
189
+ const paintOver = (paint, under) => {
190
+ const [, name = "", sep, amount = ""] = /^([\w-]+)(?:([/@])([\w.-]+))?$/.exec(paint) ?? [];
191
+ const base = token(name);
192
+ // A share is a number, or the name of a theme token holding a percentage, such as heat-5.
193
+ const share = /^[\d.]+$/.test(amount) ? Number(amount) : sep ? percentage(amount) : 100;
194
+ if (sep === "@") {
195
+ const rgb = oklchToSrgb(mixOklab(base, token("card"), share / 100));
196
+ return { rgb, name: `--${name} ${share}% in --card` };
197
+ }
198
+ const alpha = (base.alpha * share) / 100;
199
+ if (alpha === 1) return { rgb: oklchToSrgb(base), name: `--${paint}` };
200
+ return { rgb: over({ ...base, alpha }, under.rgb), name: `--${paint} over ${under.name}` };
201
+ };
202
+ for (const pair of PAIRS) {
203
+ if (pair.scheme && pair.scheme !== scheme) continue;
204
+ for (const surface of SURFACES) {
205
+ if (pair.surface && pair.surface !== surface) continue;
206
+ const page = { rgb: oklchToSrgb(token(surface)), name: `--${surface}` };
207
+ const bg = [pair.bg].flat().reduce((under, paint) => (paint ? paintOver(paint, under) : under), page);
208
+ const fg = paintOver(pair.fg, bg);
209
+ const [fgHex, bgHex] = [srgbHex(fg.rgb), srgbHex(bg.rgb)];
210
+ const ratio = contrastSrgb(fg.rgb, bg.rgb);
211
+ checks.set(`${scheme} ${fg.name} ${bg.name}`, {
212
+ scheme,
213
+ fg: fg.name,
214
+ bg: bg.name,
215
+ fgHex,
216
+ bgHex,
217
+ ratio,
218
+ min: pair.min,
219
+ });
220
+ }
221
+ }
60
222
  }
61
- return checks;
223
+ return [...checks.values()];
62
224
  }
63
225
 
64
226
  /**
65
- * Chart colours outside sRGB, named for the error message.
227
+ * Colours outside sRGB, named for the error message.
66
228
  * @param {Theme} theme
67
229
  * @returns {string[]}
68
230
  */
69
231
  export function checkGamut(theme) {
70
232
  return /** @type {const} */ (["light", "dark"]).flatMap((scheme) =>
71
- CHARTS.filter((name) => {
72
- const parsed = parseOklch(theme[scheme][name] ?? "");
73
- return parsed !== null && !inSrgb(parsed);
74
- }).map((name) => `${scheme} --${name} ${theme[scheme][name]} is outside sRGB; lower its chroma`),
233
+ Object.entries(theme[scheme])
234
+ .filter(([, value]) => {
235
+ const parsed = parseOklch(value);
236
+ return parsed !== null && !inSrgb(parsed);
237
+ })
238
+ .map(([name, value]) => `${scheme} --${name} ${value} is outside sRGB; lower its chroma`),
75
239
  );
76
240
  }
77
241
 
78
- /**
79
- * @param {Theme} theme
80
- * @param {Check} check
81
- */
82
- function describe(theme, check) {
83
- const hex = (/** @type {string} */ name) => {
84
- const parsed = parseOklch(theme[check.scheme][name] ?? "");
85
- return parsed ? toHex(parsed) : "?";
86
- };
87
- return `${check.scheme.padEnd(5)} --${check.fg} ${hex(check.fg)} on --${check.bg} ${hex(check.bg)}: ${check.ratio.toFixed(2)}:1 (needs ${check.min}:1)`;
242
+ /** @param {Check} check */
243
+ function describe(check) {
244
+ return `${check.scheme.padEnd(5)} ${check.fg} ${check.fgHex} on ${check.bg} ${check.bgHex}: ${check.ratio.toFixed(2)}:1 (needs ${check.min}:1)`;
88
245
  }
89
246
 
90
247
  // Run as a command, not when imported. A package manager starts this file
@@ -100,12 +257,12 @@ if (import.meta.url === invoked) {
100
257
  const checks = checkContrast(theme);
101
258
  const failures = checks.filter((c) => c.ratio < c.min);
102
259
  const outOfGamut = checkGamut(theme);
103
- for (const check of failures) console.error(`FAIL ${describe(theme, check)}`);
260
+ for (const check of failures) console.error(`FAIL ${describe(check)}`);
104
261
  for (const problem of outOfGamut) console.error(`FAIL ${problem}`);
105
262
  const weakest = checks.toSorted((a, b) => a.ratio / a.min - b.ratio / b.min)[0];
106
263
  console.log(
107
264
  `contrast: ${checks.length - failures.length}/${checks.length} pairs pass` +
108
- (weakest ? `; closest: ${describe(theme, weakest)}` : ""),
265
+ (weakest ? `; closest: ${describe(weakest)}` : ""),
109
266
  );
110
267
  process.exit(failures.length || outOfGamut.length ? 1 : 0);
111
268
  }
package/lint/index.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  // @ts-check
2
2
  // Oxlint (and ESLint) plugin `siyahi`. Every rule runs only in files that
3
3
  // import `@siyahi/ui`, so pages written before Siyahi stay outside the rules
4
- // without any configuration. Two rules come from @shadcn/lint as they are;
5
- // the other three cover what it does not check.
4
+ // without any configuration. Three rules come from @shadcn/lint; the other
5
+ // four cover what it does not check.
6
6
  import { plugin as shadcn } from "@shadcn/lint";
7
7
 
8
8
  /** @typedef {{ sourceCode: { text: string }, report: (problem: { node: unknown, message: string }) => void }} Context */
@@ -138,6 +138,31 @@ const noChartThemeMap = {
138
138
  }),
139
139
  };
140
140
 
141
+ /**
142
+ * Options for shadcn's no-restyle, so every app gets the same policy from
143
+ * `"siyahi/no-restyle": "error"` alone. Pages may place a component (margin,
144
+ * width, grid and flex placement); its spacing, colour, shape and type stay
145
+ * the component's. Primitives with no styles of their own are open.
146
+ */
147
+ const RESTYLE = {
148
+ allow: ["layout"],
149
+ contracts: [{ pattern: "^(Collapsible|CollapsibleContent)$", allow: ["layout", "spacing"] }],
150
+ };
151
+
152
+ /**
153
+ * Runs `rule` with `options` unless the config passes its own.
154
+ * @param {Rule} rule
155
+ * @param {object} options
156
+ * @returns {Rule}
157
+ */
158
+ function withOptions(rule, options) {
159
+ return {
160
+ ...rule,
161
+ create: (context) =>
162
+ rule.create(context.options?.length ? context : Object.create(context, { options: { value: [options] } })),
163
+ };
164
+ }
165
+
141
166
  /** @type {{ meta: { name: string }, rules: Record<string, Rule> }} */
142
167
  const plugin = {
143
168
  meta: { name: "siyahi" },
@@ -148,6 +173,7 @@ const plugin = {
148
173
  "no-chart-theme-map": scoped(noChartThemeMap),
149
174
  "no-raw-colors": scoped(shadcn.rules["no-raw-colors"]),
150
175
  "no-arbitrary-values": scoped(shadcn.rules["no-arbitrary-values"]),
176
+ "no-restyle": scoped(withOptions(shadcn.rules["no-restyle"], RESTYLE)),
151
177
  },
152
178
  };
153
179
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siyahi/tools",
3
- "version": "0.1.9-next.3",
3
+ "version": "0.1.9-next.31",
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,19 +43,12 @@
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
 
39
50
  /** @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
- ];
51
+ /** @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
52
  // How long --wait-for may take to appear before the mode fails.
50
53
  const WAIT_FOR_TIMEOUT = 15_000;
51
54
  // Tallest PNG written. Headless Chromium renders taller pages, but many image
@@ -61,16 +64,49 @@ const { values, positionals } = parseArgs({
61
64
  chrome: { type: "string", default: '[data-slot="sidebar"]' },
62
65
  "wait-for": { type: "string", default: "h1" },
63
66
  report: { type: "boolean", default: false },
67
+ widths: { type: "string", default: "1920,390" },
68
+ script: { type: "string" },
64
69
  },
65
70
  });
66
71
  if (positionals.length === 0) {
67
72
  console.error(
68
- "usage: siyahi-shots <url>... [--out shots] [--gutter 16] [--font Geist] [--chrome <selector>] [--wait-for <selector>] [--report]",
73
+ "usage: siyahi-shots <url>... [--out shots] [--gutter 16] [--font Geist] [--chrome <selector>] [--wait-for <selector>] [--report] [--widths 1920,390] [--script probe.mjs]",
69
74
  );
70
75
  process.exit(2);
71
76
  }
72
77
  const outDir = path.resolve(values.out);
78
+ const widths = values.widths.split(",").map(Number);
79
+ if (widths.some((width) => !Number.isInteger(width) || width < 240 || width > 3840)) {
80
+ console.error("--widths must be comma-separated pixel widths between 240 and 3840");
81
+ process.exit(2);
82
+ }
83
+ /** @type {Mode[]} */
84
+ const MODES = [...new Set(widths)].flatMap((width) =>
85
+ [false, true].map((dark) => ({
86
+ name: `${width}-${dark ? "dark" : "light"}`,
87
+ width,
88
+ height: width < 768 ? 844 : 1080,
89
+ dark,
90
+ })),
91
+ );
73
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;
74
110
  const waitFor = values["wait-for"];
75
111
  mkdirSync(outDir, { recursive: true });
76
112
 
@@ -93,7 +129,21 @@ const PROBE = `((chrome) => {
93
129
  };
94
130
  const shown = (el) => {
95
131
  const r = el.getBoundingClientRect();
96
- return r.width > 0 && r.height > 0 && !el.closest(chrome) && getComputedStyle(el).visibility !== "hidden";
132
+ const style = getComputedStyle(el);
133
+ // Base UI keeps clipped controls for form submission; aria-hidden alone
134
+ // does not mean a control (or its descendants) is visually hidden.
135
+ const hiddenControl = el.matches('input[aria-hidden="true"],select[aria-hidden="true"],textarea[aria-hidden="true"]') &&
136
+ (style.clipPath === "inset(50%)" || style.clip === "rect(0px, 0px, 0px, 0px)");
137
+ return r.width > 0 && r.height > 0 && !el.closest(chrome) && style.visibility !== "hidden" && !hiddenControl;
138
+ };
139
+ // Tailwind's sr-only (and a closed dialog's title kept for screen readers) clips to nothing, so it has no place
140
+ // in the gutter; the cut-off check still sees it, as a clipped visible control must not slip through.
141
+ const screenReaderOnly = (el) => {
142
+ for (let node = el; node; node = node.parentElement) {
143
+ const style = getComputedStyle(node);
144
+ if (style.clip === "rect(0px, 0px, 0px, 0px)" || style.clipPath === "inset(50%)") return true;
145
+ }
146
+ return false;
97
147
  };
98
148
  // Pixels of el cut off at the side by an ancestor that hides its overflow,
99
149
  // and how it hides it, up to the nearest one that scrolls, which shows the
@@ -112,7 +162,7 @@ const PROBE = `((chrome) => {
112
162
  };
113
163
  const visible = [...document.querySelectorAll('h1,h2,h3,h4,p,li,td,th,[data-slot="card"]')].filter((el) => {
114
164
  const r = el.getBoundingClientRect();
115
- return shown(el) && r.right > 0 && r.left < de.clientWidth && !scrollsSideways(el);
165
+ return shown(el) && !screenReaderOnly(el) && r.right > 0 && r.left < de.clientWidth && !scrollsSideways(el);
116
166
  });
117
167
  // Badges, card titles and descriptions are spans and divs, which the tag list misses.
118
168
  const clipped = [...document.querySelectorAll('h1,h2,h3,h4,p,li,td,th,a,button,input,select,textarea,label,[data-slot="badge"],[data-slot="card-title"],[data-slot="card-description"]')]
@@ -129,6 +179,18 @@ const PROBE = `((chrome) => {
129
179
  const overflowWithScrollbar = de.scrollWidth - de.clientWidth;
130
180
  de.style.overflowY = prev;
131
181
  const font = getComputedStyle(document.body).fontFamily;
182
+ const frozen = [];
183
+ for (const table of document.querySelectorAll("table")) {
184
+ let container = table.parentElement;
185
+ while (container && !["auto", "scroll"].includes(getComputedStyle(container).overflowX)) container = container.parentElement;
186
+ if (!container || container.scrollWidth <= container.clientWidth) continue;
187
+ const cells = [...(table.querySelector("tbody tr")?.children ?? [])];
188
+ const pinned = cells.filter((cell) => getComputedStyle(cell).position === "sticky");
189
+ const occupied = pinned.reduce((sum, cell) => sum + cell.getBoundingClientRect().width, 0);
190
+ if (pinned.length < cells.length && occupied > container.clientWidth / 2) {
191
+ frozen.push("frozen columns occupy " + Math.round(occupied) + " of " + container.clientWidth + "px; unpin them in narrow space");
192
+ }
193
+ }
132
194
  const first = font.split(",")[0].trim().replace(/^["']|["']$/g, "");
133
195
  const h1 = document.querySelector("h1");
134
196
  return {
@@ -137,6 +199,7 @@ const PROBE = `((chrome) => {
137
199
  overflow,
138
200
  overflowWithScrollbar,
139
201
  clipped,
202
+ frozen,
140
203
  font: first,
141
204
  fontLoaded: document.fonts.check('16px "' + first + '"') && [...document.fonts].some((f) => f.family.replace(/["']/g, "") === first && f.status === "loaded"),
142
205
  docHeight: de.scrollHeight,
@@ -165,7 +228,7 @@ function clearShots(base) {
165
228
  * @param {import("playwright").Browser} browser
166
229
  * @param {string} url
167
230
  * @param {Mode} mode
168
- * @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[] }>}
169
232
  */
170
233
  async function shoot(browser, url, mode) {
171
234
  const context = await browser.newContext({
@@ -173,6 +236,12 @@ async function shoot(browser, url, mode) {
173
236
  colorScheme: mode.dark ? "dark" : "light",
174
237
  });
175
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
+ });
176
245
  await page.goto(url, { waitUntil: "networkidle" });
177
246
  const ready = await page.waitForSelector(waitFor, { timeout: WAIT_FOR_TIMEOUT }).then(
178
247
  () => true,
@@ -182,6 +251,8 @@ async function shoot(browser, url, mode) {
182
251
  },
183
252
  );
184
253
  await page.evaluate(SETTLE);
254
+ const data = act && (await act(page, mode));
255
+ if (act) await page.evaluate(SETTLE);
185
256
  /** @type {Probe} */
186
257
  const probe = await page.evaluate(PROBE);
187
258
  const height = Math.max(probe.docHeight, mode.height);
@@ -200,16 +271,17 @@ async function shoot(browser, url, mode) {
200
271
  files.push(file);
201
272
  }
202
273
  await context.close();
203
- return { probe, ready, height, files };
274
+ return { probe, ready, height, files, data, browserErrors };
204
275
  }
205
276
 
206
277
  /**
207
278
  * @param {Probe} p
208
279
  * @param {boolean} ready whether --wait-for appeared
280
+ * @param {string[]} browserErrors
209
281
  * @returns {string[]}
210
282
  */
211
- function problemsOf(p, ready) {
212
- const problems = [];
283
+ function problemsOf(p, ready, browserErrors) {
284
+ const problems = [...browserErrors];
213
285
  if (!ready) problems.push(`nothing matching ${waitFor} appeared within ${WAIT_FOR_TIMEOUT / 1000}s (--wait-for)`);
214
286
  if (p.gutterLeft !== null && p.gutterLeft < minGutter)
215
287
  problems.push(`left gutter is ${p.gutterLeft}px, under ${minGutter}px`);
@@ -219,6 +291,7 @@ function problemsOf(p, ready) {
219
291
  if (p.overflowWithScrollbar > 0)
220
292
  problems.push(`scrolls sideways by ${p.overflowWithScrollbar}px once a scrollbar takes its width`);
221
293
  for (const what of p.clipped.slice(0, 5)) problems.push(what);
294
+ problems.push(...p.frozen);
222
295
  if (p.clipped.length > 5) problems.push(`and ${p.clipped.length - 5} more cut off`);
223
296
  if (!p.font.startsWith(values.font)) problems.push(`body font is ${p.font}, not ${values.font}`);
224
297
  else if (!p.fontLoaded && !values.report) problems.push(`${p.font} is named but never loaded`);
@@ -229,8 +302,8 @@ const browser = await chromium.launch();
229
302
  let failed = false;
230
303
  for (const url of positionals) {
231
304
  for (const mode of MODES) {
232
- const { probe, ready, height, files } = await shoot(browser, url, mode);
233
- 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);
234
307
  failed ||= problems.length > 0;
235
308
  const font = values.report && !probe.fontLoaded ? `${probe.font} (report, system fallback allowed)` : probe.font;
236
309
  console.log(
@@ -238,6 +311,7 @@ for (const url of positionals) {
238
311
  `overflow=${probe.overflow}/${probe.overflowWithScrollbar} clipped=${probe.clipped.length} font=${font} h1=${JSON.stringify(probe.h1)} ` +
239
312
  `height=${height} ${files.join(" ")}`,
240
313
  );
314
+ if (data !== undefined) console.log(` data=${JSON.stringify(data)}`);
241
315
  for (const problem of problems) console.log(` ${problem}`);
242
316
  }
243
317
  }
package/types/color.d.mts CHANGED
@@ -27,6 +27,13 @@ export declare function oklchToSrgb(color: Oklch): [number, number, number];
27
27
  * @returns {number}
28
28
  */
29
29
  export declare function luminance([r, g, b]: [number, number, number]): number;
30
+ /**
31
+ * WCAG contrast ratio between two opaque colours, as gamma-encoded sRGB.
32
+ * @param {[number, number, number]} a
33
+ * @param {[number, number, number]} b
34
+ * @returns {number}
35
+ */
36
+ export declare function contrastSrgb(a: [number, number, number], b: [number, number, number]): number;
30
37
  /**
31
38
  * WCAG contrast ratio between two opaque OKLCH colours.
32
39
  * @param {Oklch} a
@@ -34,9 +41,31 @@ export declare function luminance([r, g, b]: [number, number, number]): number;
34
41
  * @returns {number}
35
42
  */
36
43
  export declare function contrast(a: Oklch, b: Oklch): number;
44
+ /**
45
+ * The colour a translucent colour paints over an opaque one. Browsers blend
46
+ * gamma-encoded sRGB channels, so `bg-muted/50` over the card is half of each.
47
+ * @param {Oklch} top
48
+ * @param {[number, number, number]} under
49
+ * @returns {[number, number, number]}
50
+ */
51
+ export declare function over(top: Oklch, [r0, g0, b0]: [number, number, number]): [number, number, number];
52
+ /**
53
+ * `color-mix(in oklab, a share, b)`, with share in 0..1 and opaque inputs.
54
+ * @param {Oklch} a
55
+ * @param {Oklch} b
56
+ * @param {number} share
57
+ * @returns {Oklch}
58
+ */
59
+ export declare function mixOklab(a: Oklch, b: Oklch, share: number): Oklch;
37
60
  /**
38
61
  * Hex form of an OKLCH colour, for messages and tests.
39
62
  * @param {Oklch} color
40
63
  * @returns {string}
41
64
  */
42
65
  export declare function toHex(color: Oklch): string;
66
+ /**
67
+ * Hex form of gamma-encoded sRGB channels in 0..1.
68
+ * @param {[number, number, number]} rgb
69
+ * @returns {string}
70
+ */
71
+ export declare function srgbHex(rgb: [number, number, number]): string;
@@ -1,19 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
  export type Theme = import("./theme.mjs").Theme;
3
+ export type Oklch = import("./color.mjs").Oklch;
4
+ export type Scheme = "light" | "dark";
3
5
  export type Check = {
4
- scheme: "light" | "dark";
6
+ scheme: Scheme;
5
7
  fg: string;
6
8
  bg: string;
9
+ fgHex: string;
10
+ bgHex: string;
7
11
  ratio: number;
8
12
  min: number;
9
13
  };
14
+ export type Paint = string;
15
+ export type Pair = {
16
+ fg: Paint;
17
+ bg: Paint | Paint[];
18
+ min: number;
19
+ scheme?: Scheme;
20
+ surface?: "background" | "card";
21
+ };
10
22
  /**
11
23
  * @param {Theme} theme
12
24
  * @returns {Check[]}
13
25
  */
14
26
  export declare function checkContrast(theme: Theme): Check[];
15
27
  /**
16
- * Chart colours outside sRGB, named for the error message.
28
+ * Colours outside sRGB, named for the error message.
17
29
  * @param {Theme} theme
18
30
  * @returns {string[]}
19
31
  */
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;