@siyahi/tools 0.0.0-stage → 0.1.0-next.4
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/LICENSE +21 -0
- package/README.md +56 -2
- package/THIRD_PARTY_NOTICES.md +51 -0
- package/color.mjs +103 -0
- package/contrast.mjs +111 -0
- package/lint/index.mjs +160 -0
- package/package.json +54 -3
- package/shots.mjs +171 -0
- package/theme.mjs +50 -0
- package/types/color.d.mts +42 -0
- package/types/contrast.d.mts +20 -0
- package/types/lint/index.d.mts +26 -0
- package/types/shots.d.mts +17 -0
- package/types/theme.d.mts +10 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jatin Bansal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,57 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @siyahi/tools
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Checks for pages built on [`@siyahi/ui`](https://www.npmjs.com/package/@siyahi/ui).
|
|
4
|
+
Kept apart from the UI package so installing the UI does not pull in Playwright.
|
|
5
|
+
|
|
6
|
+
## Lint plugin
|
|
7
|
+
|
|
8
|
+
An [oxlint](https://oxc.rs) JS plugin, `siyahi`. Every rule runs only in files
|
|
9
|
+
that import `@siyahi/ui`.
|
|
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)) |
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"jsPlugins": ["@siyahi/tools/lint"],
|
|
23
|
+
"settings": { "shadcn": { "ui": "@siyahi/ui", "themeImports": ["^@siyahi/ui/"] } },
|
|
24
|
+
"rules": { "siyahi/no-color-literal": "error" }
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
A Vite+ or ESLint config can spread every rule at once:
|
|
29
|
+
`import { recommended } from "@siyahi/tools/lint"`.
|
|
30
|
+
|
|
31
|
+
## siyahi-shots
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
npx siyahi-shots http://localhost:5173/#/page [more URLs] [--out shots] [--gutter 16] [--font Geist] [--chrome '[data-slot="sidebar"]']
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Renders each URL in Chromium at 1920 and 390 pixels wide, light and dark, and
|
|
38
|
+
writes one full-page PNG per mode to `shots/`. It fails when visible text or a
|
|
39
|
+
card comes closer than `--gutter` pixels to either edge, when the page scrolls
|
|
40
|
+
sideways, or when the body font is not a loaded `--font` family. Content that
|
|
41
|
+
scrolls sideways inside its own box is exempt from the gutter, and so is
|
|
42
|
+
anything inside `--chrome` (by default the shadcn sidebar, which sits flush to
|
|
43
|
+
the edge by design). The font default, Geist, is Siyahi's; pass another family
|
|
44
|
+
for other pages. Run `npx playwright install chromium` once.
|
|
45
|
+
|
|
46
|
+
## siyahi-contrast
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
npx siyahi-contrast path/to/theme.css
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Reads the light `:root` block and the `@variant dark { … }` block inside it.
|
|
53
|
+
Every checked token must be set in both; dark never borrows a light value. It
|
|
54
|
+
fails unless text colours reach 4.5:1 on the canvas, the card and their own
|
|
55
|
+
fills, chart colours reach 3:1 against the card and against the slots next to
|
|
56
|
+
them, and every chart colour sits inside sRGB. Slots two apart share a
|
|
57
|
+
lightness and are told apart by hue, so series take slots in order.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
Siyahi is MIT licensed (see `LICENSE`). It contains code copied from the
|
|
4
|
+
projects below, each under the MIT licence. Every copied file starts with a
|
|
5
|
+
header naming its registry item, origin URL, licence and the SHA-256 of the
|
|
6
|
+
file as copied; `packages/ui/registry.lock.json` lists them all and
|
|
7
|
+
`pnpm generate:check` fails if a copied file is not named here.
|
|
8
|
+
|
|
9
|
+
## Copied into `@siyahi/ui`
|
|
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 (source: https://github.com/Semkoo/niko-table-registry) | MIT, Copyright (c) 2025 Semir N.; original work Copyright (c) 2025 WebDevSimplified |
|
|
19
|
+
| dashboardcn ranked list (`BarList`) | `src/components/registry/dashboardcn/bar-list.tsx` | https://dashboardcn.com/r/bar-list.json (source: https://github.com/NoahGdev/dashboardcn) | MIT, Copyright (c) 2026 dashboardcn contributors |
|
|
20
|
+
|
|
21
|
+
## Runtime dependencies with attribution terms
|
|
22
|
+
|
|
23
|
+
| Package | Licence | Note |
|
|
24
|
+
| --------------------------------------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
25
|
+
| `lightweight-charts` (TradingView) | Apache-2.0 | `PriceChart` keeps the library's attribution logo on by default; see https://github.com/tradingview/lightweight-charts/blob/master/NOTICE |
|
|
26
|
+
| `@fontsource-variable/geist`, `@fontsource-variable/geist-mono` | SIL Open Font License 1.1 (fonts), MIT (packaging) | Geist by Vercel |
|
|
27
|
+
|
|
28
|
+
Every other dependency is installed from npm under its own licence and is not
|
|
29
|
+
copied into this repository.
|
|
30
|
+
|
|
31
|
+
## The MIT licence text
|
|
32
|
+
|
|
33
|
+
Each MIT notice above carries this permission text:
|
|
34
|
+
|
|
35
|
+
> Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
36
|
+
> of this software and associated documentation files (the "Software"), to deal
|
|
37
|
+
> in the Software without restriction, including without limitation the rights
|
|
38
|
+
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
39
|
+
> copies of the Software, and to permit persons to whom the Software is
|
|
40
|
+
> furnished to do so, subject to the following conditions:
|
|
41
|
+
>
|
|
42
|
+
> The above copyright notice and this permission notice shall be included in all
|
|
43
|
+
> copies or substantial portions of the Software.
|
|
44
|
+
>
|
|
45
|
+
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
46
|
+
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
47
|
+
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
48
|
+
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
49
|
+
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
50
|
+
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
51
|
+
> SOFTWARE.
|
package/color.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// OKLCH to sRGB and WCAG 2 contrast, so the theme can be checked without a
|
|
3
|
+
// browser. Formulas from Björn Ottosson's OKLab and the WCAG 2.2 definition
|
|
4
|
+
// of relative luminance.
|
|
5
|
+
|
|
6
|
+
/** @typedef {{ l: number, c: number, h: number, alpha: number }} Oklch */
|
|
7
|
+
|
|
8
|
+
const OKLCH = /^oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)(?:deg)?\s*(?:\/\s*([\d.]+)(%?))?\s*\)$/i;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parses a CSS `oklch()` value. Returns null for anything else.
|
|
12
|
+
* @param {string} value
|
|
13
|
+
* @returns {Oklch | null}
|
|
14
|
+
*/
|
|
15
|
+
export function parseOklch(value) {
|
|
16
|
+
const m = OKLCH.exec(value.trim());
|
|
17
|
+
if (!m) return null;
|
|
18
|
+
const l = Number(m[1]) / (m[2] ? 100 : 1);
|
|
19
|
+
const alpha = m[5] === undefined ? 1 : Number(m[5]) / (m[6] ? 100 : 1);
|
|
20
|
+
return { l, c: Number(m[3]), h: Number(m[4]), alpha };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Linear-light sRGB channels of an OKLCH colour, before any clipping. A channel
|
|
25
|
+
* outside 0..1 means the colour is outside sRGB.
|
|
26
|
+
* @param {Oklch} color
|
|
27
|
+
* @returns {[number, number, number]}
|
|
28
|
+
*/
|
|
29
|
+
function linearSrgb({ l, c, h }) {
|
|
30
|
+
const hr = (h * Math.PI) / 180;
|
|
31
|
+
const a = c * Math.cos(hr);
|
|
32
|
+
const b = c * Math.sin(hr);
|
|
33
|
+
const lms = [
|
|
34
|
+
(l + 0.3963377774 * a + 0.2158037573 * b) ** 3,
|
|
35
|
+
(l - 0.1055613458 * a - 0.0638541728 * b) ** 3,
|
|
36
|
+
(l - 0.0894841775 * a - 1.291485548 * b) ** 3,
|
|
37
|
+
];
|
|
38
|
+
const [cl = 0, cm = 0, cs = 0] = lms;
|
|
39
|
+
return [
|
|
40
|
+
4.0767416621 * cl - 3.3077115913 * cm + 0.2309699292 * cs,
|
|
41
|
+
-1.2684380046 * cl + 2.6097574011 * cm - 0.3413193965 * cs,
|
|
42
|
+
-0.0041960863 * cl - 0.7034186147 * cm + 1.707614701 * cs,
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether a colour is inside sRGB, so every screen paints the value as scored.
|
|
48
|
+
* @param {Oklch} color
|
|
49
|
+
*/
|
|
50
|
+
export function inSrgb(color) {
|
|
51
|
+
return linearSrgb(color).every((v) => v >= -1e-4 && v <= 1 + 1e-4);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Converts OKLCH to gamma-encoded sRGB channels in 0..1, clipped to the gamut.
|
|
56
|
+
* @param {Oklch} color
|
|
57
|
+
* @returns {[number, number, number]}
|
|
58
|
+
*/
|
|
59
|
+
export function oklchToSrgb(color) {
|
|
60
|
+
const [r, g, bl] = linearSrgb(color).map((v) => {
|
|
61
|
+
const x = Math.min(1, Math.max(0, v));
|
|
62
|
+
return x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055;
|
|
63
|
+
});
|
|
64
|
+
return [r ?? 0, g ?? 0, bl ?? 0];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @param {number} v */
|
|
68
|
+
const lin = (v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* WCAG relative luminance of gamma-encoded sRGB channels in 0..1.
|
|
72
|
+
* @param {[number, number, number]} rgb
|
|
73
|
+
* @returns {number}
|
|
74
|
+
*/
|
|
75
|
+
export function luminance([r, g, b]) {
|
|
76
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* WCAG contrast ratio between two opaque OKLCH colours.
|
|
81
|
+
* @param {Oklch} a
|
|
82
|
+
* @param {Oklch} b
|
|
83
|
+
* @returns {number}
|
|
84
|
+
*/
|
|
85
|
+
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);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Hex form of an OKLCH colour, for messages and tests.
|
|
92
|
+
* @param {Oklch} color
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
export function toHex(color) {
|
|
96
|
+
return `#${oklchToSrgb(color)
|
|
97
|
+
.map((v) =>
|
|
98
|
+
Math.round(v * 255)
|
|
99
|
+
.toString(16)
|
|
100
|
+
.padStart(2, "0"),
|
|
101
|
+
)
|
|
102
|
+
.join("")}`;
|
|
103
|
+
}
|
package/contrast.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
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.
|
|
8
|
+
//
|
|
9
|
+
// siyahi-contrast styles/siyahi.css
|
|
10
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
11
|
+
import process from "node:process";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
import { contrast, inSrgb, parseOklch, toHex } from "./color.mjs";
|
|
14
|
+
import { readTheme } from "./theme.mjs";
|
|
15
|
+
|
|
16
|
+
/** @typedef {import("./theme.mjs").Theme} Theme */
|
|
17
|
+
/** @typedef {{ scheme: "light" | "dark", fg: string, bg: string, ratio: number, min: number }} Check */
|
|
18
|
+
|
|
19
|
+
const TEXT = ["foreground", "muted-foreground", "positive", "negative", "warning", "link", "destructive"];
|
|
20
|
+
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
|
+
const CHARTS = ["chart-1", "chart-2", "chart-3", "chart-4", "chart-5"];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {Theme} theme
|
|
36
|
+
* @returns {Check[]}
|
|
37
|
+
*/
|
|
38
|
+
export function checkContrast(theme) {
|
|
39
|
+
/** @type {Check[]} */
|
|
40
|
+
const checks = [];
|
|
41
|
+
for (const scheme of /** @type {const} */ (["light", "dark"])) {
|
|
42
|
+
const vars = theme[scheme];
|
|
43
|
+
const color = (/** @type {string} */ name) => {
|
|
44
|
+
const value = vars[name];
|
|
45
|
+
if (value === undefined) throw new Error(`${scheme}: --${name} is not set in the ${scheme} block`);
|
|
46
|
+
const parsed = parseOklch(value);
|
|
47
|
+
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
|
+
return parsed;
|
|
50
|
+
};
|
|
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
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return checks;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Chart colours outside sRGB, named for the error message.
|
|
66
|
+
* @param {Theme} theme
|
|
67
|
+
* @returns {string[]}
|
|
68
|
+
*/
|
|
69
|
+
export function checkGamut(theme) {
|
|
70
|
+
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`),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
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)`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Run as a command, not when imported. A package manager starts this file
|
|
91
|
+
// through a bin link, so compare real paths.
|
|
92
|
+
const invoked = process.argv[1] ? pathToFileURL(realpathSync(process.argv[1])).href : "";
|
|
93
|
+
if (import.meta.url === invoked) {
|
|
94
|
+
const file = process.argv[2];
|
|
95
|
+
if (!file) {
|
|
96
|
+
console.error("usage: siyahi-contrast <theme.css>");
|
|
97
|
+
process.exit(2);
|
|
98
|
+
}
|
|
99
|
+
const theme = readTheme(readFileSync(file, "utf8"));
|
|
100
|
+
const checks = checkContrast(theme);
|
|
101
|
+
const failures = checks.filter((c) => c.ratio < c.min);
|
|
102
|
+
const outOfGamut = checkGamut(theme);
|
|
103
|
+
for (const check of failures) console.error(`FAIL ${describe(theme, check)}`);
|
|
104
|
+
for (const problem of outOfGamut) console.error(`FAIL ${problem}`);
|
|
105
|
+
const weakest = checks.toSorted((a, b) => a.ratio / a.min - b.ratio / b.min)[0];
|
|
106
|
+
console.log(
|
|
107
|
+
`contrast: ${checks.length - failures.length}/${checks.length} pairs pass` +
|
|
108
|
+
(weakest ? `; closest: ${describe(theme, weakest)}` : ""),
|
|
109
|
+
);
|
|
110
|
+
process.exit(failures.length || outOfGamut.length ? 1 : 0);
|
|
111
|
+
}
|
package/lint/index.mjs
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Oxlint (and ESLint) plugin `siyahi`. Every rule runs only in files that
|
|
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.
|
|
6
|
+
import { plugin as shadcn } from "@shadcn/lint";
|
|
7
|
+
|
|
8
|
+
/** @typedef {{ sourceCode: { text: string }, report: (problem: { node: unknown, message: string }) => void }} Context */
|
|
9
|
+
/** @typedef {{ meta?: object, create: (context: any) => Record<string, (node: any) => void> }} Rule */
|
|
10
|
+
|
|
11
|
+
const IMPORTS_SIYAHI = /(?:\bfrom\s*|\bimport\s*\(?\s*)["']@siyahi\/ui(?:\/[^"']*)?["']/;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {Rule} rule
|
|
15
|
+
* @returns {Rule}
|
|
16
|
+
*/
|
|
17
|
+
function scoped(rule) {
|
|
18
|
+
return {
|
|
19
|
+
...rule,
|
|
20
|
+
create: (context) => (IMPORTS_SIYAHI.test(context.sourceCode.text) ? rule.create(context) : {}),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Calls `check` with the text of every string literal and template chunk,
|
|
26
|
+
* skipping import and export paths.
|
|
27
|
+
* @param {(node: unknown, text: string) => void} check
|
|
28
|
+
*/
|
|
29
|
+
function eachString(check) {
|
|
30
|
+
return {
|
|
31
|
+
/** @param {{ value: unknown, parent?: { type: string } }} node */
|
|
32
|
+
Literal(node) {
|
|
33
|
+
const parent = node.parent?.type ?? "";
|
|
34
|
+
if (typeof node.value !== "string" || /^(Import|Export)/.test(parent)) return;
|
|
35
|
+
check(node, node.value);
|
|
36
|
+
},
|
|
37
|
+
/** @param {{ value: { cooked?: string | null, raw: string } }} node */
|
|
38
|
+
TemplateElement(node) {
|
|
39
|
+
check(node, node.value.cooked ?? node.value.raw);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const HEX = /(?:^|[^\w&#])#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})(?![\w-])/i;
|
|
45
|
+
const COLOR_FUNCTION = /\b(?:rgba?|hsla?|hwb|oklch|oklab|lab|lch|color)\(/i;
|
|
46
|
+
|
|
47
|
+
/** @type {Rule} */
|
|
48
|
+
const noColorLiteral = {
|
|
49
|
+
meta: {
|
|
50
|
+
type: "problem",
|
|
51
|
+
docs: { description: "Disallow hex, rgb(), hsl() and oklch() colour literals; use a theme token." },
|
|
52
|
+
},
|
|
53
|
+
create: (/** @type {Context} */ context) =>
|
|
54
|
+
eachString((node, text) => {
|
|
55
|
+
if (HEX.test(text) || COLOR_FUNCTION.test(text)) {
|
|
56
|
+
context.report({
|
|
57
|
+
node,
|
|
58
|
+
message:
|
|
59
|
+
"Colour literal. Use a theme token: a class such as text-positive or bg-card, or var(--chart-1) where a library needs a value.",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const SCHEME_VARIANT = /(?:^|[\s:])(dark|light):(?=[\w[!-])/;
|
|
66
|
+
|
|
67
|
+
/** @type {Rule} */
|
|
68
|
+
const noSchemeVariant = {
|
|
69
|
+
meta: {
|
|
70
|
+
type: "problem",
|
|
71
|
+
docs: { description: "Disallow dark: and light: variants; theme tokens already change with the scheme." },
|
|
72
|
+
},
|
|
73
|
+
create: (/** @type {Context} */ context) =>
|
|
74
|
+
eachString((node, text) => {
|
|
75
|
+
const m = SCHEME_VARIANT.exec(text);
|
|
76
|
+
if (m) {
|
|
77
|
+
context.report({
|
|
78
|
+
node,
|
|
79
|
+
message: `"${m[1]}:" variant. Theme tokens follow the device's scheme on their own; use the token, not a per-scheme class.`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {{ type: string, value?: unknown, expression?: { type: string, value?: unknown } } | null | undefined} value
|
|
87
|
+
*/
|
|
88
|
+
function isNumericSize(value) {
|
|
89
|
+
const literal = value?.type === "JSXExpressionContainer" ? value.expression : value;
|
|
90
|
+
if (literal?.type !== "Literal") return false;
|
|
91
|
+
return typeof literal.value === "number" || (typeof literal.value === "string" && /^\d/.test(literal.value));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @type {Rule} */
|
|
95
|
+
const noNumericFontSize = {
|
|
96
|
+
meta: {
|
|
97
|
+
type: "problem",
|
|
98
|
+
docs: { description: "Disallow numeric font sizes; use the type scale (text-xs to text-2xl)." },
|
|
99
|
+
},
|
|
100
|
+
create: (/** @type {Context} */ context) => {
|
|
101
|
+
const message = "Numeric font size. Use the type scale: a text-xs to text-2xl class on the element.";
|
|
102
|
+
return {
|
|
103
|
+
/** @param {{ key: { type: string, name?: string, value?: unknown }, value: any }} node */
|
|
104
|
+
Property(node) {
|
|
105
|
+
const key = node.key.type === "Identifier" ? node.key.name : node.key.value;
|
|
106
|
+
if (key === "fontSize" && isNumericSize(node.value)) context.report({ node, message });
|
|
107
|
+
},
|
|
108
|
+
/** @param {{ name: { name?: string }, value: any }} node */
|
|
109
|
+
JSXAttribute(node) {
|
|
110
|
+
if (node.name.name === "fontSize" && isNumericSize(node.value)) context.report({ node, message });
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** @type {Rule} */
|
|
117
|
+
const noChartThemeMap = {
|
|
118
|
+
meta: {
|
|
119
|
+
type: "problem",
|
|
120
|
+
docs: { description: "Disallow per-scheme ChartConfig theme maps; use color: var(--chart-n)." },
|
|
121
|
+
},
|
|
122
|
+
// shadcn's ChartStyle writes a theme map's dark entry under a `.dark` class,
|
|
123
|
+
// which Siyahi never sets, so the light entry would win in dark mode.
|
|
124
|
+
create: (/** @type {Context} */ context) => ({
|
|
125
|
+
/** @param {{ key: { type: string, name?: string, value?: unknown }, value: { type: string, properties?: { key?: { name?: string, value?: unknown } }[] } }} node */
|
|
126
|
+
Property(node) {
|
|
127
|
+
const key = node.key.type === "Identifier" ? node.key.name : node.key.value;
|
|
128
|
+
if (key !== "theme" || node.value.type !== "ObjectExpression") return;
|
|
129
|
+
const keys = new Set((node.value.properties ?? []).map((p) => p.key?.name ?? p.key?.value));
|
|
130
|
+
if (keys.has("light") || keys.has("dark")) {
|
|
131
|
+
context.report({
|
|
132
|
+
node,
|
|
133
|
+
message:
|
|
134
|
+
'Chart theme map. Its dark entry only applies under a .dark class, which Siyahi never sets. Use color: "var(--chart-1)"; the variable already follows the scheme.',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** @type {{ meta: { name: string }, rules: Record<string, Rule> }} */
|
|
142
|
+
const plugin = {
|
|
143
|
+
meta: { name: "siyahi" },
|
|
144
|
+
rules: {
|
|
145
|
+
"no-color-literal": scoped(noColorLiteral),
|
|
146
|
+
"no-scheme-variant": scoped(noSchemeVariant),
|
|
147
|
+
"no-numeric-font-size": scoped(noNumericFontSize),
|
|
148
|
+
"no-chart-theme-map": scoped(noChartThemeMap),
|
|
149
|
+
"no-raw-colors": scoped(shadcn.rules["no-raw-colors"]),
|
|
150
|
+
"no-arbitrary-values": scoped(shadcn.rules["no-arbitrary-values"]),
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Every rule at "error", for a Vite+ or ESLint config to spread.
|
|
156
|
+
* @type {Record<string, "error">}
|
|
157
|
+
*/
|
|
158
|
+
export const recommended = Object.fromEntries(Object.keys(plugin.rules).map((name) => [`siyahi/${name}`, "error"]));
|
|
159
|
+
|
|
160
|
+
export default plugin;
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@siyahi/tools",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0-next.4",
|
|
4
|
+
"description": "Checks for Siyahi pages: a lint plugin, a theme contrast check and a screenshot script.",
|
|
5
|
+
"homepage": "https://siyahi.jatinbansal.com",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Jatin Bansal",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/jatinbansal1998/siyahi.git",
|
|
11
|
+
"directory": "packages/tools"
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"siyahi-contrast": "./contrast.mjs",
|
|
15
|
+
"siyahi-shots": "./shots.mjs"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"*.mjs",
|
|
19
|
+
"lint/*.mjs",
|
|
20
|
+
"types",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE",
|
|
23
|
+
"THIRD_PARTY_NOTICES.md"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"exports": {
|
|
27
|
+
"./lint": {
|
|
28
|
+
"types": "./types/lint/index.d.mts",
|
|
29
|
+
"default": "./lint/index.mjs"
|
|
30
|
+
},
|
|
31
|
+
"./contrast": {
|
|
32
|
+
"types": "./types/contrast.d.mts",
|
|
33
|
+
"default": "./contrast.mjs"
|
|
34
|
+
},
|
|
35
|
+
"./theme": {
|
|
36
|
+
"types": "./types/theme.d.mts",
|
|
37
|
+
"default": "./theme.mjs"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@shadcn/lint": "0.2.3",
|
|
42
|
+
"playwright": "^1.64.1"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "24.19.2",
|
|
46
|
+
"oxlint": "^1.88.0",
|
|
47
|
+
"typescript": "7.0.2",
|
|
48
|
+
"vite-plus": "1.3.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"oxlint": ">=1.80.0"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "tsc -p tsconfig.build.json",
|
|
55
|
+
"typecheck": "tsc --noEmit"
|
|
56
|
+
}
|
|
6
57
|
}
|
package/shots.mjs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
// Renders pages in Playwright's Chromium at 1920 and 390 wide, light and dark,
|
|
4
|
+
// checks the layout rules every Siyahi page keeps, and writes one full-page
|
|
5
|
+
// PNG per page and mode.
|
|
6
|
+
//
|
|
7
|
+
// siyahi-shots <url>... [--out shots] [--gutter 16] [--font Geist]
|
|
8
|
+
// [--chrome '[data-slot="sidebar"]']
|
|
9
|
+
//
|
|
10
|
+
// Checks: a gutter of at least --gutter px on both sides of visible text and
|
|
11
|
+
// cards, no sideways document scroll (also with a classic scrollbar taking its
|
|
12
|
+
// width), and --font as the loaded body font. Exempt from the gutter: content
|
|
13
|
+
// inside a box that scrolls sideways, and anything inside --chrome, which by
|
|
14
|
+
// default is only the shadcn sidebar, flush to the edge by design. The font
|
|
15
|
+
// default is Siyahi's; other pages pass their own family.
|
|
16
|
+
//
|
|
17
|
+
// The capture first grows the viewport to the page's height and waits, so
|
|
18
|
+
// charts that redraw on resize settle before the picture is taken; a
|
|
19
|
+
// stitched full-page capture catches them mid-animation.
|
|
20
|
+
import { mkdirSync } from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import process from "node:process";
|
|
23
|
+
import { parseArgs } from "node:util";
|
|
24
|
+
import { chromium } from "playwright";
|
|
25
|
+
|
|
26
|
+
/** @typedef {{ name: string, width: number, height: number, dark: boolean }} Mode */
|
|
27
|
+
/** @typedef {{ gutterLeft: number | null, gutterRight: number | null, overflow: number, overflowWithScrollbar: number, font: string, fontLoaded: boolean, docHeight: number, h1: string | null }} Probe */
|
|
28
|
+
|
|
29
|
+
/** @type {Mode[]} */
|
|
30
|
+
const MODES = [
|
|
31
|
+
{ name: "1920-light", width: 1920, height: 1080, dark: false },
|
|
32
|
+
{ name: "1920-dark", width: 1920, height: 1080, dark: true },
|
|
33
|
+
{ name: "390-light", width: 390, height: 844, dark: false },
|
|
34
|
+
{ name: "390-dark", width: 390, height: 844, dark: true },
|
|
35
|
+
];
|
|
36
|
+
const MAX_HEIGHT = 16000;
|
|
37
|
+
|
|
38
|
+
const { values, positionals } = parseArgs({
|
|
39
|
+
allowPositionals: true,
|
|
40
|
+
options: {
|
|
41
|
+
out: { type: "string", default: "shots" },
|
|
42
|
+
gutter: { type: "string", default: "16" },
|
|
43
|
+
font: { type: "string", default: "Geist" },
|
|
44
|
+
chrome: { type: "string", default: '[data-slot="sidebar"]' },
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
if (positionals.length === 0) {
|
|
48
|
+
console.error("usage: siyahi-shots <url>... [--out shots] [--gutter 16] [--font Geist] [--chrome <selector>]");
|
|
49
|
+
process.exit(2);
|
|
50
|
+
}
|
|
51
|
+
const outDir = path.resolve(values.out);
|
|
52
|
+
const minGutter = Number(values.gutter);
|
|
53
|
+
mkdirSync(outDir, { recursive: true });
|
|
54
|
+
|
|
55
|
+
/** @param {string} url */
|
|
56
|
+
function slugOf(url) {
|
|
57
|
+
const u = new URL(url);
|
|
58
|
+
const slug = `${u.pathname}${u.hash}`.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "");
|
|
59
|
+
return slug || "index";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Runs in the page. Kept as a string so the bundled code is exactly what runs.
|
|
63
|
+
const PROBE = `((chrome) => {
|
|
64
|
+
const de = document.documentElement;
|
|
65
|
+
const scrollsSideways = (el) => {
|
|
66
|
+
for (let e = el.parentElement; e; e = e.parentElement) {
|
|
67
|
+
const o = getComputedStyle(e).overflowX;
|
|
68
|
+
if ((o === "auto" || o === "scroll") && e.scrollWidth > e.clientWidth) return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
};
|
|
72
|
+
const visible = [...document.querySelectorAll('h1,h2,h3,h4,p,li,td,th,[data-slot="card"]')].filter((el) => {
|
|
73
|
+
const r = el.getBoundingClientRect();
|
|
74
|
+
if (r.width === 0 || r.height === 0) return false;
|
|
75
|
+
if (r.right <= 0 || r.left >= de.clientWidth) return false;
|
|
76
|
+
if (el.closest(chrome)) return false;
|
|
77
|
+
const s = getComputedStyle(el);
|
|
78
|
+
return s.visibility !== "hidden" && !scrollsSideways(el);
|
|
79
|
+
});
|
|
80
|
+
const lefts = visible.map((el) => el.getBoundingClientRect().left);
|
|
81
|
+
const rights = visible.map((el) => de.clientWidth - el.getBoundingClientRect().right);
|
|
82
|
+
const overflow = de.scrollWidth - de.clientWidth;
|
|
83
|
+
const prev = de.style.overflowY;
|
|
84
|
+
de.style.overflowY = "scroll";
|
|
85
|
+
void document.body.offsetHeight;
|
|
86
|
+
const overflowWithScrollbar = de.scrollWidth - de.clientWidth;
|
|
87
|
+
de.style.overflowY = prev;
|
|
88
|
+
const font = getComputedStyle(document.body).fontFamily;
|
|
89
|
+
const first = font.split(",")[0].trim().replace(/^["']|["']$/g, "");
|
|
90
|
+
const h1 = document.querySelector("h1");
|
|
91
|
+
return {
|
|
92
|
+
gutterLeft: lefts.length ? Math.round(Math.min(...lefts)) : null,
|
|
93
|
+
gutterRight: rights.length ? Math.round(Math.min(...rights)) : null,
|
|
94
|
+
overflow,
|
|
95
|
+
overflowWithScrollbar,
|
|
96
|
+
font: first,
|
|
97
|
+
fontLoaded: document.fonts.check('16px "' + first + '"') && [...document.fonts].some((f) => f.family.replace(/["']/g, "") === first && f.status === "loaded"),
|
|
98
|
+
docHeight: de.scrollHeight,
|
|
99
|
+
h1: h1 ? h1.textContent.trim().slice(0, 60) : null,
|
|
100
|
+
};
|
|
101
|
+
})(${JSON.stringify(values.chrome)})`;
|
|
102
|
+
|
|
103
|
+
const SETTLE = `(async () => {
|
|
104
|
+
await document.fonts.ready;
|
|
105
|
+
await Promise.all([...document.images].map((image) => image.decode().catch(() => {})));
|
|
106
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
107
|
+
})()`;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {import("playwright").Browser} browser
|
|
111
|
+
* @param {string} url
|
|
112
|
+
* @param {Mode} mode
|
|
113
|
+
* @returns {Promise<{ probe: Probe, file: string }>}
|
|
114
|
+
*/
|
|
115
|
+
async function shoot(browser, url, mode) {
|
|
116
|
+
const context = await browser.newContext({
|
|
117
|
+
viewport: { width: mode.width, height: mode.height },
|
|
118
|
+
colorScheme: mode.dark ? "dark" : "light",
|
|
119
|
+
});
|
|
120
|
+
const page = await context.newPage();
|
|
121
|
+
await page.goto(url, { waitUntil: "networkidle" });
|
|
122
|
+
await page.evaluate(SETTLE);
|
|
123
|
+
/** @type {Probe} */
|
|
124
|
+
const probe = await page.evaluate(PROBE);
|
|
125
|
+
await page.setViewportSize({
|
|
126
|
+
width: mode.width,
|
|
127
|
+
height: Math.min(Math.max(probe.docHeight, mode.height), MAX_HEIGHT),
|
|
128
|
+
});
|
|
129
|
+
await page.waitForTimeout(1500);
|
|
130
|
+
await page.evaluate(SETTLE);
|
|
131
|
+
const file = path.join(outDir, `${slugOf(url)}-${mode.name}.png`);
|
|
132
|
+
await page.screenshot({ path: file });
|
|
133
|
+
await context.close();
|
|
134
|
+
return { probe, file };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {Probe} p
|
|
139
|
+
* @returns {string[]}
|
|
140
|
+
*/
|
|
141
|
+
function problemsOf(p) {
|
|
142
|
+
const problems = [];
|
|
143
|
+
if (p.gutterLeft !== null && p.gutterLeft < minGutter)
|
|
144
|
+
problems.push(`left gutter is ${p.gutterLeft}px, under ${minGutter}px`);
|
|
145
|
+
if (p.gutterRight !== null && p.gutterRight < minGutter)
|
|
146
|
+
problems.push(`right gutter is ${p.gutterRight}px, under ${minGutter}px`);
|
|
147
|
+
if (p.overflow > 0) problems.push(`document scrolls sideways by ${p.overflow}px`);
|
|
148
|
+
if (p.overflowWithScrollbar > 0)
|
|
149
|
+
problems.push(`scrolls sideways by ${p.overflowWithScrollbar}px once a scrollbar takes its width`);
|
|
150
|
+
if (!p.font.startsWith(values.font)) problems.push(`body font is ${p.font}, not ${values.font}`);
|
|
151
|
+
else if (!p.fontLoaded) problems.push(`${p.font} is named but never loaded`);
|
|
152
|
+
if (p.docHeight > MAX_HEIGHT) problems.push(`page is ${p.docHeight}px tall; the shot stops at ${MAX_HEIGHT}px`);
|
|
153
|
+
return problems;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const browser = await chromium.launch();
|
|
157
|
+
let failed = false;
|
|
158
|
+
for (const url of positionals) {
|
|
159
|
+
for (const mode of MODES) {
|
|
160
|
+
const { probe, file } = await shoot(browser, url, mode);
|
|
161
|
+
const problems = problemsOf(probe);
|
|
162
|
+
failed ||= problems.length > 0;
|
|
163
|
+
console.log(
|
|
164
|
+
`${problems.length ? "FAIL" : "ok "} ${mode.name.padEnd(10)} gutter=${probe.gutterLeft}/${probe.gutterRight} ` +
|
|
165
|
+
`overflow=${probe.overflow}/${probe.overflowWithScrollbar} font=${probe.font} h1=${JSON.stringify(probe.h1)} ${file}`,
|
|
166
|
+
);
|
|
167
|
+
for (const problem of problems) console.log(` ${problem}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
await browser.close();
|
|
171
|
+
process.exit(failed ? 1 : 0);
|
package/theme.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Reads the custom properties of a Siyahi theme file: the top-level `:root`
|
|
3
|
+
// block for light, and the `@variant dark { … }` block nested inside it for
|
|
4
|
+
// dark. Dark holds only what its own block declares: a token the dark block
|
|
5
|
+
// forgets must fail the checks, not borrow its light value.
|
|
6
|
+
|
|
7
|
+
/** @typedef {Record<string, string>} Variables */
|
|
8
|
+
/** @typedef {{ light: Variables, dark: Variables }} Theme */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The text between the brace that opens at `open` and its matching close.
|
|
12
|
+
* @param {string} css @param {number} open
|
|
13
|
+
*/
|
|
14
|
+
function blockAt(css, open) {
|
|
15
|
+
let depth = 0;
|
|
16
|
+
for (let i = open; i < css.length; i++) {
|
|
17
|
+
if (css[i] === "{") depth++;
|
|
18
|
+
else if (css[i] === "}" && --depth === 0) return css.slice(open + 1, i);
|
|
19
|
+
}
|
|
20
|
+
throw new Error("theme has an unclosed block");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} body
|
|
25
|
+
* @returns {Variables}
|
|
26
|
+
*/
|
|
27
|
+
function declarations(body) {
|
|
28
|
+
/** @type {Variables} */
|
|
29
|
+
const vars = {};
|
|
30
|
+
for (const m of body.matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)) {
|
|
31
|
+
if (m[1] && m[2]) vars[m[1]] = m[2].trim();
|
|
32
|
+
}
|
|
33
|
+
return vars;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} css
|
|
38
|
+
* @returns {Theme}
|
|
39
|
+
*/
|
|
40
|
+
export function readTheme(css) {
|
|
41
|
+
const source = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
42
|
+
const rootAt = source.search(/(?:^|\n):root\s*\{/);
|
|
43
|
+
if (rootAt === -1) throw new Error("theme has no top-level :root block");
|
|
44
|
+
const root = blockAt(source, source.indexOf("{", rootAt));
|
|
45
|
+
const variantAt = root.search(/@variant\s+dark\s*\{/);
|
|
46
|
+
if (variantAt === -1) throw new Error("theme has no `@variant dark { … }` block inside :root");
|
|
47
|
+
const dark = blockAt(root, root.indexOf("{", variantAt));
|
|
48
|
+
const light = root.replace(`{${dark}}`, "{}");
|
|
49
|
+
return { light: declarations(light), dark: declarations(dark) };
|
|
50
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type Oklch = {
|
|
2
|
+
l: number;
|
|
3
|
+
c: number;
|
|
4
|
+
h: number;
|
|
5
|
+
alpha: number;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Parses a CSS `oklch()` value. Returns null for anything else.
|
|
9
|
+
* @param {string} value
|
|
10
|
+
* @returns {Oklch | null}
|
|
11
|
+
*/
|
|
12
|
+
export declare function parseOklch(value: string): Oklch | null;
|
|
13
|
+
/**
|
|
14
|
+
* Whether a colour is inside sRGB, so every screen paints the value as scored.
|
|
15
|
+
* @param {Oklch} color
|
|
16
|
+
*/
|
|
17
|
+
export declare function inSrgb(color: Oklch): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Converts OKLCH to gamma-encoded sRGB channels in 0..1, clipped to the gamut.
|
|
20
|
+
* @param {Oklch} color
|
|
21
|
+
* @returns {[number, number, number]}
|
|
22
|
+
*/
|
|
23
|
+
export declare function oklchToSrgb(color: Oklch): [number, number, number];
|
|
24
|
+
/**
|
|
25
|
+
* WCAG relative luminance of gamma-encoded sRGB channels in 0..1.
|
|
26
|
+
* @param {[number, number, number]} rgb
|
|
27
|
+
* @returns {number}
|
|
28
|
+
*/
|
|
29
|
+
export declare function luminance([r, g, b]: [number, number, number]): number;
|
|
30
|
+
/**
|
|
31
|
+
* WCAG contrast ratio between two opaque OKLCH colours.
|
|
32
|
+
* @param {Oklch} a
|
|
33
|
+
* @param {Oklch} b
|
|
34
|
+
* @returns {number}
|
|
35
|
+
*/
|
|
36
|
+
export declare function contrast(a: Oklch, b: Oklch): number;
|
|
37
|
+
/**
|
|
38
|
+
* Hex form of an OKLCH colour, for messages and tests.
|
|
39
|
+
* @param {Oklch} color
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
export declare function toHex(color: Oklch): string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export type Theme = import("./theme.mjs").Theme;
|
|
3
|
+
export type Check = {
|
|
4
|
+
scheme: "light" | "dark";
|
|
5
|
+
fg: string;
|
|
6
|
+
bg: string;
|
|
7
|
+
ratio: number;
|
|
8
|
+
min: number;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* @param {Theme} theme
|
|
12
|
+
* @returns {Check[]}
|
|
13
|
+
*/
|
|
14
|
+
export declare function checkContrast(theme: Theme): Check[];
|
|
15
|
+
/**
|
|
16
|
+
* Chart colours outside sRGB, named for the error message.
|
|
17
|
+
* @param {Theme} theme
|
|
18
|
+
* @returns {string[]}
|
|
19
|
+
*/
|
|
20
|
+
export declare function checkGamut(theme: Theme): string[];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type Context = {
|
|
2
|
+
sourceCode: {
|
|
3
|
+
text: string;
|
|
4
|
+
};
|
|
5
|
+
report: (problem: {
|
|
6
|
+
node: unknown;
|
|
7
|
+
message: string;
|
|
8
|
+
}) => void;
|
|
9
|
+
};
|
|
10
|
+
export type Rule = {
|
|
11
|
+
meta?: object;
|
|
12
|
+
create: (context: any) => Record<string, (node: any) => void>;
|
|
13
|
+
};
|
|
14
|
+
/** @type {{ meta: { name: string }, rules: Record<string, Rule> }} */
|
|
15
|
+
declare const plugin: {
|
|
16
|
+
meta: {
|
|
17
|
+
name: string;
|
|
18
|
+
};
|
|
19
|
+
rules: Record<string, Rule>;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Every rule at "error", for a Vite+ or ESLint config to spread.
|
|
23
|
+
* @type {Record<string, "error">}
|
|
24
|
+
*/
|
|
25
|
+
export declare const recommended: Record<string, "error">;
|
|
26
|
+
export default plugin;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export type Mode = {
|
|
3
|
+
name: string;
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
dark: boolean;
|
|
7
|
+
};
|
|
8
|
+
export type Probe = {
|
|
9
|
+
gutterLeft: number | null;
|
|
10
|
+
gutterRight: number | null;
|
|
11
|
+
overflow: number;
|
|
12
|
+
overflowWithScrollbar: number;
|
|
13
|
+
font: string;
|
|
14
|
+
fontLoaded: boolean;
|
|
15
|
+
docHeight: number;
|
|
16
|
+
h1: string | null;
|
|
17
|
+
};
|