@siyahi/tools 0.1.9-next.21 → 0.1.9-next.23
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 +27 -13
- package/color.mjs +53 -3
- package/contrast.mjs +146 -52
- package/lint/index.mjs +28 -2
- package/package.json +1 -1
- package/types/color.d.mts +29 -0
- package/types/contrast.d.mts +13 -2
package/README.md
CHANGED
|
@@ -12,14 +12,15 @@ that import `@siyahi/ui`.
|
|
|
12
12
|
npm install --save-dev @siyahi/tools@next oxlint
|
|
13
13
|
```
|
|
14
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))
|
|
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)) |
|
|
23
24
|
|
|
24
25
|
```json
|
|
25
26
|
{
|
|
@@ -31,11 +32,20 @@ npm install --save-dev @siyahi/tools@next oxlint
|
|
|
31
32
|
"siyahi/no-numeric-font-size": "error",
|
|
32
33
|
"siyahi/no-chart-theme-map": "error",
|
|
33
34
|
"siyahi/no-raw-colors": "error",
|
|
34
|
-
"siyahi/no-arbitrary-values": "error"
|
|
35
|
+
"siyahi/no-arbitrary-values": "error",
|
|
36
|
+
"siyahi/no-restyle": "error"
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
39
|
```
|
|
38
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
|
+
|
|
39
49
|
A Vite+ or ESLint config can spread every rule at once:
|
|
40
50
|
`import { recommended } from "@siyahi/tools/lint"`.
|
|
41
51
|
|
|
@@ -131,7 +141,11 @@ npx siyahi-contrast path/to/theme.css
|
|
|
131
141
|
|
|
132
142
|
Reads the light `:root` block and the `@variant dark { … }` block inside it.
|
|
133
143
|
Every checked token must be set in both; dark never borrows a light value. It
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
144
|
+
checks each pair on the canvas and on the card, blending a translucent fill over
|
|
145
|
+
the surface under it. It fails unless text reaches 4.5:1 on the page, on its own
|
|
146
|
+
fill and on the tints Siyahi's components put it on (Callout washes, the
|
|
147
|
+
destructive tint and its hover, code-line fills, treemap heat steps), a field's
|
|
148
|
+
edge, the focus ring, a selected tab and every chart and meaning colour reach
|
|
149
|
+
3:1 against what is next to them, and every colour sits inside sRGB. Slots two
|
|
150
|
+
apart share a lightness and are told apart by hue, so series take slots in
|
|
151
|
+
order.
|
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,184 @@
|
|
|
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.
|
|
27
|
+
* @typedef {string} Paint
|
|
28
|
+
*/
|
|
29
|
+
/** @typedef {{ fg: Paint, bg: Paint, min: number, scheme?: Scheme }} Pair */
|
|
18
30
|
|
|
19
|
-
const TEXT =
|
|
31
|
+
const TEXT = 4.5;
|
|
32
|
+
const MARK = 3;
|
|
20
33
|
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
34
|
const CHARTS = ["chart-1", "chart-2", "chart-3", "chart-4", "chart-5"];
|
|
33
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The light label on each treemap heat step, both sides.
|
|
38
|
+
* @param {Scheme} scheme @param {number[]} shares
|
|
39
|
+
* @returns {Pair[]}
|
|
40
|
+
*/
|
|
41
|
+
const heatLabels = (scheme, shares) =>
|
|
42
|
+
["positive", "negative"].flatMap((side) =>
|
|
43
|
+
shares.map((share) => ({ fg: "foreground", bg: `${side}@${share}`, min: TEXT, scheme })),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/** @type {Pair[]} */
|
|
47
|
+
const PAIRS = [
|
|
48
|
+
// Text on the page and on each token's own fill.
|
|
49
|
+
...["foreground", "muted-foreground", "positive", "negative", "warning", "link", "destructive"].map((fg) => ({
|
|
50
|
+
fg,
|
|
51
|
+
bg: "",
|
|
52
|
+
min: TEXT,
|
|
53
|
+
})),
|
|
54
|
+
...[
|
|
55
|
+
["card-foreground", "card"],
|
|
56
|
+
["popover-foreground", "popover"],
|
|
57
|
+
["primary-foreground", "primary"],
|
|
58
|
+
["secondary-foreground", "secondary"],
|
|
59
|
+
["accent-foreground", "accent"],
|
|
60
|
+
["positive-foreground", "positive"],
|
|
61
|
+
["negative-foreground", "negative"],
|
|
62
|
+
["warning-foreground", "warning"],
|
|
63
|
+
["sidebar-foreground", "sidebar"],
|
|
64
|
+
["sidebar-primary-foreground", "sidebar-primary"],
|
|
65
|
+
["sidebar-accent-foreground", "sidebar-accent"],
|
|
66
|
+
// A selected toggle or tab: the foreground fill with the canvas colour as text.
|
|
67
|
+
["background", "foreground"],
|
|
68
|
+
].map(([fg = "", bg = ""]) => ({ fg, bg, min: TEXT })),
|
|
69
|
+
// Muted text on the fills it sits on: avatar initials and highlighted code
|
|
70
|
+
// lines (muted), a neutral Callout, CardFooter and the command field
|
|
71
|
+
// (muted/50), a Quote's source (muted/60), the accent, warning, positive and
|
|
72
|
+
// negative Callouts, StatusAlerts and StatusBadges, a removed code line
|
|
73
|
+
// (destructive/10).
|
|
74
|
+
...["muted", "muted/50", "muted/60", "chart-1/8", "warning/10", "positive/8", "negative/8", "destructive/10"].map(
|
|
75
|
+
(bg) => ({ fg: "muted-foreground", bg, min: TEXT }),
|
|
76
|
+
),
|
|
77
|
+
// A destructive button or badge: its tint at rest and on hover, a light
|
|
78
|
+
// menu item's focus, and a dark input's own fill under its text.
|
|
79
|
+
{ fg: "destructive", bg: "destructive/10", min: TEXT, scheme: "light" },
|
|
80
|
+
{ fg: "destructive", bg: "destructive/20", min: TEXT },
|
|
81
|
+
{ fg: "destructive", bg: "destructive/30", min: TEXT, scheme: "dark" },
|
|
82
|
+
{ fg: "foreground", bg: "input/30", min: TEXT, scheme: "dark" },
|
|
83
|
+
{ fg: "muted-foreground", bg: "input/30", min: TEXT, scheme: "dark" },
|
|
84
|
+
// A field's edge against the page, against its own fill (a slider's muted
|
|
85
|
+
// track, the command field, a dark field), so the box reads as a box. A
|
|
86
|
+
// focus ring and an invalid field's edge recolour that edge and are read
|
|
87
|
+
// against the page.
|
|
88
|
+
...["", "muted", "muted/50"].map((bg) => ({ fg: "input", bg, min: MARK })),
|
|
89
|
+
{ fg: "input", bg: "input/30", min: MARK, scheme: "dark" },
|
|
90
|
+
...["ring", "destructive"].map((fg) => ({ fg, bg: "", min: MARK })),
|
|
91
|
+
// A tone's icon or StatusBadge dot against the tone's own wash (lib/tone.ts).
|
|
92
|
+
...[
|
|
93
|
+
["link", "chart-1/8"],
|
|
94
|
+
["warning", "warning/10"],
|
|
95
|
+
["positive", "positive/8"],
|
|
96
|
+
["negative", "negative/8"],
|
|
97
|
+
].map(([fg = "", bg = ""]) => ({ fg, bg, min: MARK })),
|
|
98
|
+
// The selected tab's fill against its list.
|
|
99
|
+
{ fg: "foreground", bg: "muted", min: MARK },
|
|
100
|
+
// Chart marks on the canvas and the card. A dimmed bar keeps its full colour
|
|
101
|
+
// as its outline, so these are the outlines too.
|
|
102
|
+
...[...CHARTS, "positive", "negative"].map((fg) => ({ fg, bg: "", min: MARK })),
|
|
103
|
+
...CHARTS.slice(0, -1).map((fg, i) => ({ fg, bg: CHARTS[i + 1] ?? "", min: MARK })),
|
|
104
|
+
// Treemap labels on every heat step (lib/treemap.ts heatFill): 12% to 60% of
|
|
105
|
+
// the meaning colour in the card in light, 25% to 60% in dark.
|
|
106
|
+
...heatLabels("light", [12, 24, 36, 48, 60]),
|
|
107
|
+
...heatLabels("dark", [25, 33.75, 42.5, 51.25, 60]),
|
|
108
|
+
];
|
|
109
|
+
|
|
34
110
|
/**
|
|
35
111
|
* @param {Theme} theme
|
|
36
112
|
* @returns {Check[]}
|
|
37
113
|
*/
|
|
38
114
|
export function checkContrast(theme) {
|
|
39
|
-
/** @type {Check
|
|
40
|
-
const checks =
|
|
115
|
+
/** @type {Map<string, Check>} */
|
|
116
|
+
const checks = new Map();
|
|
41
117
|
for (const scheme of /** @type {const} */ (["light", "dark"])) {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
const value = vars[name];
|
|
118
|
+
const token = (/** @type {string} */ name) => {
|
|
119
|
+
const value = theme[scheme][name];
|
|
45
120
|
if (value === undefined) throw new Error(`${scheme}: --${name} is not set in the ${scheme} block`);
|
|
46
121
|
const parsed = parseOklch(value);
|
|
47
122
|
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
123
|
return parsed;
|
|
50
124
|
};
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
if (
|
|
59
|
-
|
|
125
|
+
/**
|
|
126
|
+
* The sRGB a paint shows over what lies under it, and how a message names it.
|
|
127
|
+
* @param {Paint} paint @param {{ rgb: [number, number, number], name: string }} under
|
|
128
|
+
*/
|
|
129
|
+
const paintOver = (paint, under) => {
|
|
130
|
+
const [, name = "", sep, amount] = /^([\w-]+)(?:([/@])([\d.]+))?$/.exec(paint) ?? [];
|
|
131
|
+
const base = token(name);
|
|
132
|
+
if (sep === "@") {
|
|
133
|
+
const rgb = oklchToSrgb(mixOklab(base, token("card"), Number(amount) / 100));
|
|
134
|
+
return { rgb, name: `--${name} ${amount}% in --card` };
|
|
135
|
+
}
|
|
136
|
+
const alpha = base.alpha * (sep === "/" ? Number(amount) / 100 : 1);
|
|
137
|
+
if (alpha === 1) return { rgb: oklchToSrgb(base), name: `--${paint}` };
|
|
138
|
+
return { rgb: over({ ...base, alpha }, under.rgb), name: `--${paint} over ${under.name}` };
|
|
139
|
+
};
|
|
140
|
+
for (const pair of PAIRS) {
|
|
141
|
+
if (pair.scheme && pair.scheme !== scheme) continue;
|
|
142
|
+
for (const surface of SURFACES) {
|
|
143
|
+
const page = { rgb: oklchToSrgb(token(surface)), name: `--${surface}` };
|
|
144
|
+
const bg = pair.bg ? paintOver(pair.bg, page) : page;
|
|
145
|
+
const fg = paintOver(pair.fg, bg);
|
|
146
|
+
const [fgHex, bgHex] = [srgbHex(fg.rgb), srgbHex(bg.rgb)];
|
|
147
|
+
const ratio = contrastSrgb(fg.rgb, bg.rgb);
|
|
148
|
+
checks.set(`${scheme} ${fg.name} ${bg.name}`, {
|
|
149
|
+
scheme,
|
|
150
|
+
fg: fg.name,
|
|
151
|
+
bg: bg.name,
|
|
152
|
+
fgHex,
|
|
153
|
+
bgHex,
|
|
154
|
+
ratio,
|
|
155
|
+
min: pair.min,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
60
159
|
}
|
|
61
|
-
return checks;
|
|
160
|
+
return [...checks.values()];
|
|
62
161
|
}
|
|
63
162
|
|
|
64
163
|
/**
|
|
65
|
-
*
|
|
164
|
+
* Colours outside sRGB, named for the error message.
|
|
66
165
|
* @param {Theme} theme
|
|
67
166
|
* @returns {string[]}
|
|
68
167
|
*/
|
|
69
168
|
export function checkGamut(theme) {
|
|
70
169
|
return /** @type {const} */ (["light", "dark"]).flatMap((scheme) =>
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
170
|
+
Object.entries(theme[scheme])
|
|
171
|
+
.filter(([, value]) => {
|
|
172
|
+
const parsed = parseOklch(value);
|
|
173
|
+
return parsed !== null && !inSrgb(parsed);
|
|
174
|
+
})
|
|
175
|
+
.map(([name, value]) => `${scheme} --${name} ${value} is outside sRGB; lower its chroma`),
|
|
75
176
|
);
|
|
76
177
|
}
|
|
77
178
|
|
|
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)`;
|
|
179
|
+
/** @param {Check} check */
|
|
180
|
+
function describe(check) {
|
|
181
|
+
return `${check.scheme.padEnd(5)} ${check.fg} ${check.fgHex} on ${check.bg} ${check.bgHex}: ${check.ratio.toFixed(2)}:1 (needs ${check.min}:1)`;
|
|
88
182
|
}
|
|
89
183
|
|
|
90
184
|
// Run as a command, not when imported. A package manager starts this file
|
|
@@ -100,12 +194,12 @@ if (import.meta.url === invoked) {
|
|
|
100
194
|
const checks = checkContrast(theme);
|
|
101
195
|
const failures = checks.filter((c) => c.ratio < c.min);
|
|
102
196
|
const outOfGamut = checkGamut(theme);
|
|
103
|
-
for (const check of failures) console.error(`FAIL ${describe(
|
|
197
|
+
for (const check of failures) console.error(`FAIL ${describe(check)}`);
|
|
104
198
|
for (const problem of outOfGamut) console.error(`FAIL ${problem}`);
|
|
105
199
|
const weakest = checks.toSorted((a, b) => a.ratio / a.min - b.ratio / b.min)[0];
|
|
106
200
|
console.log(
|
|
107
201
|
`contrast: ${checks.length - failures.length}/${checks.length} pairs pass` +
|
|
108
|
-
(weakest ? `; closest: ${describe(
|
|
202
|
+
(weakest ? `; closest: ${describe(weakest)}` : ""),
|
|
109
203
|
);
|
|
110
204
|
process.exit(failures.length || outOfGamut.length ? 1 : 0);
|
|
111
205
|
}
|
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.23",
|
|
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/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,30 @@
|
|
|
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;
|
|
18
|
+
min: number;
|
|
19
|
+
scheme?: Scheme;
|
|
20
|
+
};
|
|
10
21
|
/**
|
|
11
22
|
* @param {Theme} theme
|
|
12
23
|
* @returns {Check[]}
|
|
13
24
|
*/
|
|
14
25
|
export declare function checkContrast(theme: Theme): Check[];
|
|
15
26
|
/**
|
|
16
|
-
*
|
|
27
|
+
* Colours outside sRGB, named for the error message.
|
|
17
28
|
* @param {Theme} theme
|
|
18
29
|
* @returns {string[]}
|
|
19
30
|
*/
|