@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 +75 -14
- package/THIRD_PARTY_NOTICES.md +11 -11
- package/color.mjs +53 -3
- package/contrast.mjs +209 -52
- package/lint/index.mjs +28 -2
- package/package.json +1 -1
- package/shots.mjs +92 -18
- package/types/color.d.mts +29 -0
- package/types/contrast.d.mts +14 -2
- package/types/shots.d.mts +1 -0
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
|
16
|
-
|
|
|
17
|
-
| `siyahi/no-
|
|
18
|
-
| `siyahi/no-
|
|
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": {
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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.
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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
|
|
12
|
-
|
|
|
13
|
-
| shadcn/ui core components (installed with `shadcn add`, never edited)
|
|
14
|
-
| shadcn `tailwind.css` (variants and utilities the components use)
|
|
15
|
-
| shadcn block `sidebar-07`
|
|
16
|
-
| shadcn block `dashboard-01`, `SectionCards` only
|
|
17
|
-
| Heatmaps: calendar, weekday by hour, status timeline
|
|
18
|
-
| niko-table data table on TanStack Table
|
|
19
|
-
| shadcn chart blocks `chart-bar-multiple`, `chart-area-stacked`, `chart-line-multiple`
|
|
20
|
-
| dashboardcn ranked list (`BarList`)
|
|
21
|
-
| MiniDev copy button (`CopyButton`)
|
|
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
|
-
|
|
87
|
-
|
|
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
|
|
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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 {
|
|
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 {
|
|
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 =
|
|
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
|
|
43
|
-
|
|
44
|
-
const
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
5
|
-
//
|
|
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
|
+
"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
|
-
|
|
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;
|
package/types/contrast.d.mts
CHANGED
|
@@ -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:
|
|
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
|
-
*
|
|
28
|
+
* Colours outside sRGB, named for the error message.
|
|
17
29
|
* @param {Theme} theme
|
|
18
30
|
* @returns {string[]}
|
|
19
31
|
*/
|