@siyahi/tools 0.0.0-stage → 0.1.0-next.3

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 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
- # Temporary Holding Version
1
+ # @siyahi/tools
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0-next.3",
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
+ };
@@ -0,0 +1,10 @@
1
+ export type Variables = Record<string, string>;
2
+ export type Theme = {
3
+ light: Variables;
4
+ dark: Variables;
5
+ };
6
+ /**
7
+ * @param {string} css
8
+ * @returns {Theme}
9
+ */
10
+ export declare function readTheme(css: string): Theme;