@siyahi/tools 0.1.9-next.21 → 0.1.9-next.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
- fails unless text colours reach 4.5:1 on the canvas, the card and their own
135
- fills, chart colours reach 3:1 against the card and against the slots next to
136
- them, and every chart colour sits inside sRGB. Slots two apart share a
137
- lightness and are told apart by hue, so series take slots in order.
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
- const [hi, lo] = [luminance(oklchToSrgb(a)), luminance(oklchToSrgb(b))].toSorted((x, y) => y - x);
87
- return ((hi ?? 0) + 0.05) / ((lo ?? 0) + 0.05);
97
+ return contrastSrgb(oklchToSrgb(a), oklchToSrgb(b));
98
+ }
99
+
100
+ /**
101
+ * The colour a translucent colour paints over an opaque one. Browsers blend
102
+ * gamma-encoded sRGB channels, so `bg-muted/50` over the card is half of each.
103
+ * @param {Oklch} top
104
+ * @param {[number, number, number]} under
105
+ * @returns {[number, number, number]}
106
+ */
107
+ export function over(top, [r0, g0, b0]) {
108
+ const [r, g, b] = oklchToSrgb(top);
109
+ const blend = (/** @type {number} */ fg, /** @type {number} */ bg) => top.alpha * fg + (1 - top.alpha) * bg;
110
+ return [blend(r, r0), blend(g, g0), blend(b, b0)];
111
+ }
112
+
113
+ /** @param {Oklch} color @returns {[number, number, number]} */
114
+ const toOklab = ({ l, c, h }) => [l, c * Math.cos((h * Math.PI) / 180), c * Math.sin((h * Math.PI) / 180)];
115
+
116
+ /**
117
+ * `color-mix(in oklab, a share, b)`, with share in 0..1 and opaque inputs.
118
+ * @param {Oklch} a
119
+ * @param {Oklch} b
120
+ * @param {number} share
121
+ * @returns {Oklch}
122
+ */
123
+ export function mixOklab(a, b, share) {
124
+ const [la, xa, ya] = toOklab(a);
125
+ const [lb, xb, yb] = toOklab(b);
126
+ const along = (/** @type {number} */ from, /** @type {number} */ to) => share * from + (1 - share) * to;
127
+ const [x, y] = [along(xa, xb), along(ya, yb)];
128
+ return { l: along(la, lb), c: Math.hypot(x, y), h: (Math.atan2(y, x) * 180) / Math.PI, alpha: 1 };
88
129
  }
89
130
 
90
131
  /**
@@ -93,7 +134,16 @@ export function contrast(a, b) {
93
134
  * @returns {string}
94
135
  */
95
136
  export function toHex(color) {
96
- return `#${oklchToSrgb(color)
137
+ return srgbHex(oklchToSrgb(color));
138
+ }
139
+
140
+ /**
141
+ * Hex form of gamma-encoded sRGB channels in 0..1.
142
+ * @param {[number, number, number]} rgb
143
+ * @returns {string}
144
+ */
145
+ export function srgbHex(rgb) {
146
+ return `#${rgb
97
147
  .map((v) =>
98
148
  Math.round(v * 255)
99
149
  .toString(16)
package/contrast.mjs CHANGED
@@ -1,90 +1,176 @@
1
1
  #!/usr/bin/env node
2
2
  // @ts-check
3
- // Fails when a theme colour is too faint to read: 4.5:1 for text on the
4
- // canvas and the card and for a foreground on its own fill, 3:1 for chart
5
- // colours against the card and against the slot next to them. Slots two apart
6
- // share a lightness and are told apart by hue, so they are not compared. Chart
7
- // colours must also sit inside sRGB, so the browser paints what was scored.
3
+ // Fails when a theme colour is too faint where Siyahi's components paint it:
4
+ // 4.5:1 for text, 3:1 for a mark a reader needs (a control's edge, a focus
5
+ // ring, a selected tab, a chart mark). Each pair is checked on both surfaces a
6
+ // block can sit on, the canvas and the card, with translucent fills blended
7
+ // over that surface as the browser paints them. Chart slots two apart share a
8
+ // lightness and are told apart by hue, so they are not compared. Every colour
9
+ // must also sit inside sRGB, so the browser paints what was scored.
8
10
  //
9
11
  // siyahi-contrast styles/siyahi.css
10
12
  import { readFileSync, realpathSync } from "node:fs";
11
13
  import process from "node:process";
12
14
  import { pathToFileURL } from "node:url";
13
- import { contrast, inSrgb, parseOklch, toHex } from "./color.mjs";
15
+ import { contrastSrgb, inSrgb, mixOklab, oklchToSrgb, over, parseOklch, srgbHex } from "./color.mjs";
14
16
  import { readTheme } from "./theme.mjs";
15
17
 
16
18
  /** @typedef {import("./theme.mjs").Theme} Theme */
17
- /** @typedef {{ scheme: "light" | "dark", fg: string, bg: string, ratio: number, min: number }} Check */
19
+ /** @typedef {import("./color.mjs").Oklch} Oklch */
20
+ /** @typedef {"light" | "dark"} Scheme */
21
+ /** @typedef {{ scheme: Scheme, fg: string, bg: string, fgHex: string, bgHex: string, ratio: number, min: number }} Check */
22
+ /**
23
+ * What a component paints, written as Tailwind writes it:
24
+ * `muted` is the token, `muted/50` the token at 50% over what lies under it,
25
+ * and `positive@60` is `color-mix(in oklab, var(--positive) 60%, var(--card))`,
26
+ * a treemap tile.
27
+ * @typedef {string} Paint
28
+ */
29
+ /** @typedef {{ fg: Paint, bg: Paint, min: number, scheme?: Scheme }} Pair */
18
30
 
19
- const TEXT = ["foreground", "muted-foreground", "positive", "negative", "warning", "link", "destructive"];
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 and WarningAlert, a removed code line (destructive/10).
73
+ ...["muted", "muted/50", "muted/60", "chart-1/8", "warning/10", "positive/8", "negative/8", "destructive/10"].map(
74
+ (bg) => ({ fg: "muted-foreground", bg, min: TEXT }),
75
+ ),
76
+ // A destructive button or badge: its tint at rest and on hover, a light
77
+ // menu item's focus, and a dark input's own fill under its text.
78
+ { fg: "destructive", bg: "destructive/10", min: TEXT, scheme: "light" },
79
+ { fg: "destructive", bg: "destructive/20", min: TEXT },
80
+ { fg: "destructive", bg: "destructive/30", min: TEXT, scheme: "dark" },
81
+ { fg: "foreground", bg: "input/30", min: TEXT, scheme: "dark" },
82
+ { fg: "muted-foreground", bg: "input/30", min: TEXT, scheme: "dark" },
83
+ // A field's edge against the page, against its own fill (a slider's muted
84
+ // track, the command field, a dark field), so the box reads as a box. A
85
+ // focus ring and an invalid field's edge recolour that edge and are read
86
+ // against the page.
87
+ ...["", "muted", "muted/50"].map((bg) => ({ fg: "input", bg, min: MARK })),
88
+ { fg: "input", bg: "input/30", min: MARK, scheme: "dark" },
89
+ ...["ring", "destructive"].map((fg) => ({ fg, bg: "", min: MARK })),
90
+ // The selected tab's fill against its list.
91
+ { fg: "foreground", bg: "muted", min: MARK },
92
+ // Chart marks on the canvas and the card. A dimmed bar keeps its full colour
93
+ // as its outline, so these are the outlines too.
94
+ ...[...CHARTS, "positive", "negative"].map((fg) => ({ fg, bg: "", min: MARK })),
95
+ ...CHARTS.slice(0, -1).map((fg, i) => ({ fg, bg: CHARTS[i + 1] ?? "", min: MARK })),
96
+ // Treemap labels on every heat step (lib/treemap.ts heatFill): 12% to 60% of
97
+ // the meaning colour in the card in light, 25% to 60% in dark.
98
+ ...heatLabels("light", [12, 24, 36, 48, 60]),
99
+ ...heatLabels("dark", [25, 33.75, 42.5, 51.25, 60]),
100
+ ];
101
+
34
102
  /**
35
103
  * @param {Theme} theme
36
104
  * @returns {Check[]}
37
105
  */
38
106
  export function checkContrast(theme) {
39
- /** @type {Check[]} */
40
- const checks = [];
107
+ /** @type {Map<string, Check>} */
108
+ const checks = new Map();
41
109
  for (const scheme of /** @type {const} */ (["light", "dark"])) {
42
- const vars = theme[scheme];
43
- const color = (/** @type {string} */ name) => {
44
- const value = vars[name];
110
+ const token = (/** @type {string} */ name) => {
111
+ const value = theme[scheme][name];
45
112
  if (value === undefined) throw new Error(`${scheme}: --${name} is not set in the ${scheme} block`);
46
113
  const parsed = parseOklch(value);
47
114
  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
115
  return parsed;
50
116
  };
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
- });
117
+ /**
118
+ * The sRGB a paint shows over what lies under it, and how a message names it.
119
+ * @param {Paint} paint @param {{ rgb: [number, number, number], name: string }} under
120
+ */
121
+ const paintOver = (paint, under) => {
122
+ const [, name = "", sep, amount] = /^([\w-]+)(?:([/@])([\d.]+))?$/.exec(paint) ?? [];
123
+ const base = token(name);
124
+ if (sep === "@") {
125
+ const rgb = oklchToSrgb(mixOklab(base, token("card"), Number(amount) / 100));
126
+ return { rgb, name: `--${name} ${amount}% in --card` };
127
+ }
128
+ const alpha = base.alpha * (sep === "/" ? Number(amount) / 100 : 1);
129
+ if (alpha === 1) return { rgb: oklchToSrgb(base), name: `--${paint}` };
130
+ return { rgb: over({ ...base, alpha }, under.rgb), name: `--${paint} over ${under.name}` };
131
+ };
132
+ for (const pair of PAIRS) {
133
+ if (pair.scheme && pair.scheme !== scheme) continue;
134
+ for (const surface of SURFACES) {
135
+ const page = { rgb: oklchToSrgb(token(surface)), name: `--${surface}` };
136
+ const bg = pair.bg ? paintOver(pair.bg, page) : page;
137
+ const fg = paintOver(pair.fg, bg);
138
+ const [fgHex, bgHex] = [srgbHex(fg.rgb), srgbHex(bg.rgb)];
139
+ const ratio = contrastSrgb(fg.rgb, bg.rgb);
140
+ checks.set(`${scheme} ${fg.name} ${bg.name}`, {
141
+ scheme,
142
+ fg: fg.name,
143
+ bg: bg.name,
144
+ fgHex,
145
+ bgHex,
146
+ ratio,
147
+ min: pair.min,
148
+ });
149
+ }
150
+ }
60
151
  }
61
- return checks;
152
+ return [...checks.values()];
62
153
  }
63
154
 
64
155
  /**
65
- * Chart colours outside sRGB, named for the error message.
156
+ * Colours outside sRGB, named for the error message.
66
157
  * @param {Theme} theme
67
158
  * @returns {string[]}
68
159
  */
69
160
  export function checkGamut(theme) {
70
161
  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`),
162
+ Object.entries(theme[scheme])
163
+ .filter(([, value]) => {
164
+ const parsed = parseOklch(value);
165
+ return parsed !== null && !inSrgb(parsed);
166
+ })
167
+ .map(([name, value]) => `${scheme} --${name} ${value} is outside sRGB; lower its chroma`),
75
168
  );
76
169
  }
77
170
 
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)`;
171
+ /** @param {Check} check */
172
+ function describe(check) {
173
+ return `${check.scheme.padEnd(5)} ${check.fg} ${check.fgHex} on ${check.bg} ${check.bgHex}: ${check.ratio.toFixed(2)}:1 (needs ${check.min}:1)`;
88
174
  }
89
175
 
90
176
  // Run as a command, not when imported. A package manager starts this file
@@ -100,12 +186,12 @@ if (import.meta.url === invoked) {
100
186
  const checks = checkContrast(theme);
101
187
  const failures = checks.filter((c) => c.ratio < c.min);
102
188
  const outOfGamut = checkGamut(theme);
103
- for (const check of failures) console.error(`FAIL ${describe(theme, check)}`);
189
+ for (const check of failures) console.error(`FAIL ${describe(check)}`);
104
190
  for (const problem of outOfGamut) console.error(`FAIL ${problem}`);
105
191
  const weakest = checks.toSorted((a, b) => a.ratio / a.min - b.ratio / b.min)[0];
106
192
  console.log(
107
193
  `contrast: ${checks.length - failures.length}/${checks.length} pairs pass` +
108
- (weakest ? `; closest: ${describe(theme, weakest)}` : ""),
194
+ (weakest ? `; closest: ${describe(weakest)}` : ""),
109
195
  );
110
196
  process.exit(failures.length || outOfGamut.length ? 1 : 0);
111
197
  }
package/lint/index.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  // @ts-check
2
2
  // Oxlint (and ESLint) plugin `siyahi`. Every rule runs only in files that
3
3
  // import `@siyahi/ui`, so pages written before Siyahi stay outside the rules
4
- // without any configuration. Two rules come from @shadcn/lint as they are;
5
- // the other three cover what it does not check.
4
+ // without any configuration. Three rules come from @shadcn/lint; the other
5
+ // four cover what it does not check.
6
6
  import { plugin as shadcn } from "@shadcn/lint";
7
7
 
8
8
  /** @typedef {{ sourceCode: { text: string }, report: (problem: { node: unknown, message: string }) => void }} Context */
@@ -138,6 +138,31 @@ const noChartThemeMap = {
138
138
  }),
139
139
  };
140
140
 
141
+ /**
142
+ * Options for shadcn's no-restyle, so every app gets the same policy from
143
+ * `"siyahi/no-restyle": "error"` alone. Pages may place a component (margin,
144
+ * width, grid and flex placement); its spacing, colour, shape and type stay
145
+ * the component's. Primitives with no styles of their own are open.
146
+ */
147
+ const RESTYLE = {
148
+ allow: ["layout"],
149
+ contracts: [{ pattern: "^(Collapsible|CollapsibleContent)$", allow: ["layout", "spacing"] }],
150
+ };
151
+
152
+ /**
153
+ * Runs `rule` with `options` unless the config passes its own.
154
+ * @param {Rule} rule
155
+ * @param {object} options
156
+ * @returns {Rule}
157
+ */
158
+ function withOptions(rule, options) {
159
+ return {
160
+ ...rule,
161
+ create: (context) =>
162
+ rule.create(context.options?.length ? context : Object.create(context, { options: { value: [options] } })),
163
+ };
164
+ }
165
+
141
166
  /** @type {{ meta: { name: string }, rules: Record<string, Rule> }} */
142
167
  const plugin = {
143
168
  meta: { name: "siyahi" },
@@ -148,6 +173,7 @@ const plugin = {
148
173
  "no-chart-theme-map": scoped(noChartThemeMap),
149
174
  "no-raw-colors": scoped(shadcn.rules["no-raw-colors"]),
150
175
  "no-arbitrary-values": scoped(shadcn.rules["no-arbitrary-values"]),
176
+ "no-restyle": scoped(withOptions(shadcn.rules["no-restyle"], RESTYLE)),
151
177
  },
152
178
  };
153
179
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siyahi/tools",
3
- "version": "0.1.9-next.21",
3
+ "version": "0.1.9-next.22",
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;
@@ -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: "light" | "dark";
6
+ scheme: Scheme;
5
7
  fg: string;
6
8
  bg: string;
9
+ fgHex: string;
10
+ bgHex: string;
7
11
  ratio: number;
8
12
  min: number;
9
13
  };
14
+ export type Paint = string;
15
+ export type Pair = {
16
+ fg: Paint;
17
+ bg: Paint;
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
- * Chart colours outside sRGB, named for the error message.
27
+ * Colours outside sRGB, named for the error message.
17
28
  * @param {Theme} theme
18
29
  * @returns {string[]}
19
30
  */