onsystem 0.6.0 → 0.7.0

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.
@@ -4,10 +4,10 @@ import { pathToFileURL } from "node:url";
4
4
  import * as z from "zod";
5
5
  import { escapePath, glob, globSync } from "tinyglobby";
6
6
  import ts from "typescript";
7
+ import os from "node:os";
7
8
  import { clampChroma, colorsNamed, converter, differenceEuclidean, formatHex, parse } from "culori";
8
9
  import { createHash } from "node:crypto";
9
10
  import fsp from "node:fs/promises";
10
- import os from "node:os";
11
11
  import postcss from "postcss";
12
12
  import { LineCounter, isMap, isScalar, parseDocument, stringify } from "yaml";
13
13
  import { stemmer } from "stemmer";
@@ -23,13 +23,36 @@ const DEFAULT_OPTIONS = {
23
23
  resolveJsonModule: true
24
24
  };
25
25
  /**
26
- * Reads the project's tsconfig (only from inside the root: walking further up
27
- * would pick up unrelated configs, e.g. this repository's own when extracting
28
- * test fixtures).
26
+ * The configs read from a project's root without a `tsconfig` option, in order: a
27
+ * JavaScript app keeps its `paths` in jsconfig.json (create-next-app, shadcn/ui with
28
+ * `"tsx": false`), which TypeScript reads when there is no tsconfig.json.
29
+ */
30
+ const PROJECT_CONFIG_FILES = ["tsconfig.json", "jsconfig.json"];
31
+ /**
32
+ * Build output, which a jsconfig.json's default `include` takes, as its `.js` files are
33
+ * sources to TypeScript: the folders libraries/load.ts skips without a config, and VS Code's
34
+ * jsconfig docs advise excluding.
35
+ */
36
+ const BUILD_OUTPUT = /* @__PURE__ */ new Set([
37
+ "dist",
38
+ "build",
39
+ "out",
40
+ ".next",
41
+ "coverage",
42
+ "public"
43
+ ]);
44
+ /** The root's tsconfig.json, else its jsconfig.json. */
45
+ function defaultProjectConfig(root) {
46
+ return PROJECT_CONFIG_FILES.map((file) => path.join(root, file)).find((file) => fs.existsSync(file));
47
+ }
48
+ /**
49
+ * Reads the project's tsconfig, or its jsconfig without one (only from inside
50
+ * the root: walking further up would pick up unrelated configs, e.g. this
51
+ * repository's own when extracting test fixtures).
29
52
  */
30
53
  function readProjectConfig(root, tsconfig) {
31
- const configFile = tsconfig ? path.resolve(root, tsconfig) : path.join(root, "tsconfig.json");
32
- if (!fs.existsSync(configFile)) return {
54
+ const configFile = tsconfig ? path.resolve(root, tsconfig) : defaultProjectConfig(root);
55
+ if (!configFile || !fs.existsSync(configFile)) return {
33
56
  options: { ...DEFAULT_OPTIONS },
34
57
  pathsBase: root,
35
58
  paths: {},
@@ -55,7 +78,10 @@ function readProjectConfig(root, tsconfig) {
55
78
  return ts.sys.readFile(file);
56
79
  }
57
80
  };
58
- const parsed = ts.parseJsonConfigFileContent(read.config, host, path.dirname(configFile));
81
+ const dir = path.dirname(configFile);
82
+ const jsconfig = path.basename(configFile) === "jsconfig.json";
83
+ const parsed = ts.parseJsonConfigFileContent(read.config, host, dir, jsconfig ? { allowJs: true } : void 0);
84
+ const fileNames = jsconfig && parsed.raw?.exclude === void 0 ? parsed.fileNames.filter((file) => !inBuildOutput(path.relative(dir, file))) : parsed.fileNames;
59
85
  const options = {
60
86
  ...DEFAULT_OPTIONS,
61
87
  ...parsed.options
@@ -69,13 +95,17 @@ function readProjectConfig(root, tsconfig) {
69
95
  delete options.tsBuildInfoFile;
70
96
  return {
71
97
  options,
72
- pathsBase: options.baseUrl ?? (typeof options.pathsBasePath === "string" ? options.pathsBasePath : path.dirname(configFile)),
98
+ pathsBase: options.baseUrl ?? (typeof options.pathsBasePath === "string" ? options.pathsBasePath : dir),
73
99
  paths: options.paths ?? {},
74
100
  configFile,
75
101
  configFiles,
76
- fileNames: parsed.fileNames
102
+ fileNames
77
103
  };
78
104
  }
105
+ /** Whether a file, by its path relative to the config, is in a build output folder. */
106
+ function inBuildOutput(relative) {
107
+ return relative.split(/[\\/]/).slice(0, -1).some((part) => BUILD_OUTPUT.has(part));
108
+ }
79
109
  function createProgram(files, config, oldProgram) {
80
110
  return ts.createProgram({
81
111
  rootNames: files,
@@ -116,6 +146,24 @@ function isInside(root, file) {
116
146
  const rel = path.relative(root, file);
117
147
  return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
118
148
  }
149
+ /**
150
+ * Whether `dir` is the home folder, a folder that holds it, or a filesystem root, links
151
+ * resolved. None of them is a project's workspace or repository, though a stray
152
+ * `~/package.json` with `workspaces`, or a repository of dotfiles, makes one look like it, and
153
+ * from there a `../` path reaches `~/.aws/credentials`.
154
+ */
155
+ function isHomeOrAbove(dir) {
156
+ const home = os.homedir();
157
+ const holdsHome = (d, h) => d === path.parse(d).root || isInside(d, h);
158
+ return holdsHome(path.resolve(dir), path.resolve(home)) || holdsHome(realOrSelf(dir), realOrSelf(home));
159
+ }
160
+ function realOrSelf(p) {
161
+ try {
162
+ return fs.realpathSync(p);
163
+ } catch {
164
+ return path.resolve(p);
165
+ }
166
+ }
119
167
  /** The static directory part of a glob (`components/ui/**\/*.tsx` → `components/ui`). */
120
168
  function globBase(glob) {
121
169
  const parts = toPosix(glob).split("/");
@@ -778,7 +826,7 @@ function fromComponentsJson(root, dir, json, project, packages, note = ignore) {
778
826
  const pkgDir = name ? packages.find(name) : void 0;
779
827
  const pkg = pkgDir ? readJson$1(path.join(pkgDir, "package.json")) : void 0;
780
828
  if (!name || !pkgDir || !pkg) {
781
- note(`the ui alias ${ui} resolves through neither tsconfig paths nor a workspace package`);
829
+ note(`the ui alias ${ui} resolves through neither ${project.configFile ? path.basename(project.configFile) : "tsconfig"} paths nor a workspace package`);
782
830
  return;
783
831
  }
784
832
  imports = exportMappings(root, pkgDir, pkg);
@@ -1090,6 +1138,86 @@ function moduleResolver(root) {
1090
1138
  };
1091
1139
  }
1092
1140
  /**
1141
+ * {@link moduleResolver} for a Tailwind config's presets, then a workspace package's
1142
+ * `exports` for what it leaves unresolved: `@repo/tailwind-config` through
1143
+ * `"exports": { ".": "./tailwind.config.ts" }`, as Turborepo's template has it.
1144
+ */
1145
+ function presetResolver(root) {
1146
+ const resolve = moduleResolver(root);
1147
+ const packages = new PackageFinder(root);
1148
+ return (specifier, fromFile) => {
1149
+ const file = resolve(specifier, fromFile);
1150
+ if (file || specifier.startsWith(".")) return file;
1151
+ const name = packageName(specifier);
1152
+ const dir = name ? packages.find(name) : void 0;
1153
+ return name && dir ? exportedFile(dir, `.${specifier.slice(name.length)}`, (f) => SCRIPT_FILE.test(f)) : void 0;
1154
+ };
1155
+ }
1156
+ /**
1157
+ * Resolves a stylesheet's `@import` to a project file: a path next to the stylesheet
1158
+ * (`./theme.css`, or `theme.css` as plain CSS reads it), or a stylesheet of a workspace
1159
+ * package (`@acme/theme/colors.css`, or `@acme/theme` through its `exports` or its `style`
1160
+ * field). Installed packages (`tailwindcss`) and URLs resolve to nothing.
1161
+ */
1162
+ function stylesheetResolver(root) {
1163
+ const packages = new PackageFinder(root);
1164
+ const stylesheet = (file) => [file, `${file}.css`].find((f) => f.endsWith(".css") && !/[\\/]node_modules[\\/]/.test(f) && isFile(f));
1165
+ return (specifier, fromFile) => {
1166
+ if (/^(?:[a-z][\w+.-]*:|\/)/i.test(specifier)) return void 0;
1167
+ const relative = stylesheet(path.resolve(path.dirname(fromFile), specifier));
1168
+ if (relative || specifier.startsWith(".")) return relative;
1169
+ const name = packageName(specifier);
1170
+ const dir = name ? packages.find(name) : void 0;
1171
+ if (!name || !dir) return void 0;
1172
+ const subpath = specifier.slice(name.length + 1);
1173
+ const style = readJson$1(path.join(dir, "package.json"))?.style;
1174
+ return exportedFile(dir, subpath ? `./${subpath}` : ".", (f) => f.endsWith(".css")) ?? (subpath ? stylesheet(path.join(dir, subpath)) : stylesheet(path.resolve(dir, typeof style === "string" ? style : "index.css")));
1175
+ };
1176
+ }
1177
+ /**
1178
+ * The file a package's `exports` map `subpath` (`.`, `./theme`) to, through any condition
1179
+ * (`style`, `import`, `default`) and `*` patterns: the first target on disk, inside the
1180
+ * package, that `accept` takes.
1181
+ */
1182
+ function exportedFile(dir, subpath, accept) {
1183
+ const exports = readJson$1(path.join(dir, "package.json"))?.exports;
1184
+ const entries = exports && typeof exports === "object" && !Array.isArray(exports) ? Object.keys(exports).every((k) => k.startsWith(".")) ? Object.entries(exports) : [[".", exports]] : exports === void 0 ? [] : [[".", exports]];
1185
+ const matches = [];
1186
+ for (const [key, value] of entries) {
1187
+ const star = key.indexOf("*");
1188
+ if (star === -1) {
1189
+ if (key === subpath) matches.push({
1190
+ middle: "",
1191
+ value,
1192
+ rank: Infinity
1193
+ });
1194
+ continue;
1195
+ }
1196
+ const prefix = key.slice(0, star);
1197
+ const suffix = key.slice(star + 1);
1198
+ if (subpath.startsWith(prefix) && subpath.endsWith(suffix) && subpath.length >= prefix.length + suffix.length) {
1199
+ const middle = subpath.slice(prefix.length, subpath.length - suffix.length);
1200
+ matches.push({
1201
+ middle,
1202
+ value,
1203
+ rank: prefix.length
1204
+ });
1205
+ }
1206
+ }
1207
+ matches.sort((a, b) => b.rank - a.rank);
1208
+ for (const { middle, value } of matches) for (const target of exportTargets(value)) {
1209
+ const file = path.resolve(dir, target.replaceAll("*", middle));
1210
+ if (isInside(dir, file) && accept(file) && isFile(file)) return file;
1211
+ }
1212
+ }
1213
+ /** Every target of an export condition map, in order, but its types. */
1214
+ function exportTargets(value) {
1215
+ if (typeof value === "string") return [value];
1216
+ if (Array.isArray(value)) return value.flatMap(exportTargets);
1217
+ if (value && typeof value === "object") return Object.entries(value).flatMap(([condition, nested]) => condition === "types" ? [] : exportTargets(nested));
1218
+ return [];
1219
+ }
1220
+ /**
1093
1221
  * Where an import from a workspace package points: its file, or `{}` for a
1094
1222
  * subpath the package's `exports` declare but whose target is not on disk
1095
1223
  * (built to `dist/`). Undefined when no such module exists. The linter tells
@@ -1305,24 +1433,47 @@ var PackageFinder = class {
1305
1433
  }
1306
1434
  return this.nodeModules;
1307
1435
  }
1308
- /** The workspace root when `dir` is in it, else `dir` itself. */
1436
+ /**
1437
+ * The workspace root when `dir` is in it, else `dir` itself. Never the home folder or above:
1438
+ * a stray `~/package.json` with `workspaces` would let a package's config name
1439
+ * `../../../.aws/credentials`.
1440
+ */
1309
1441
  boundaryOf(dir) {
1310
- if (this.workspace === void 0) this.workspace = workspaceRootOf(this.root) ?? null;
1442
+ if (this.workspace === void 0) {
1443
+ const workspace = workspaceRootOf(this.root);
1444
+ this.workspace = workspace !== void 0 && !isHomeOrAbove(workspace) ? workspace : null;
1445
+ }
1311
1446
  return this.workspace !== null && isInside(this.workspace, dir) ? this.workspace : dir;
1312
1447
  }
1313
1448
  };
1314
1449
  /**
1315
- * A monorepo root: a pnpm-workspace.yaml, `workspaces` in package.json (npm, Yarn, Bun),
1316
- * a lerna.json with `packages`, or an nx.json. Turborepo runs on the package manager's
1317
- * workspaces, so its roots are among these.
1450
+ * A monorepo root: a pnpm-workspace.yaml whose `packages` name more than the root,
1451
+ * `workspaces` in package.json (npm, Yarn, Bun), a lerna.json with `packages`, or an nx.json.
1452
+ * Turborepo runs on the package manager's workspaces, so its roots are among these. pnpm 11
1453
+ * keeps its settings in a pnpm-workspace.yaml in a single app too (`allowBuilds`, as
1454
+ * create-next-app --use-pnpm leaves it), and reads one without `packages`, or with only
1455
+ * `- '.'`, as the root alone: that is not a monorepo.
1318
1456
  */
1319
1457
  function isWorkspaceRoot(dir) {
1320
- if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return true;
1458
+ if (pnpmWorkspacePackages(dir).length) return true;
1321
1459
  if (fs.existsSync(path.join(dir, "nx.json"))) return true;
1322
1460
  const pkg = readJson$1(path.join(dir, "package.json"));
1323
1461
  if (pkg !== void 0 && pkg.workspaces !== void 0) return true;
1324
1462
  return Array.isArray(readJson$1(path.join(dir, "lerna.json"))?.packages);
1325
1463
  }
1464
+ /**
1465
+ * The `packages` of the pnpm-workspace.yaml in `dir` when they name a package other than the
1466
+ * root (`.`); none otherwise, and none without the file: then it holds only settings.
1467
+ */
1468
+ function pnpmWorkspacePackages(dir) {
1469
+ let list;
1470
+ try {
1471
+ list = pnpmPackages(fs.readFileSync(path.join(dir, "pnpm-workspace.yaml"), "utf8"));
1472
+ } catch {
1473
+ return [];
1474
+ }
1475
+ return list.some((p) => !p.startsWith("!") && path.posix.normalize(toPosix(p).replace(/\/+$/, "")) !== ".") ? list : [];
1476
+ }
1326
1477
  /** The nearest workspace root at or above `start`. */
1327
1478
  function workspaceRootOf(start) {
1328
1479
  for (let dir = start;; dir = path.dirname(dir)) {
@@ -1380,7 +1531,8 @@ function stampOf(files) {
1380
1531
  }
1381
1532
  /**
1382
1533
  * `packages` from pnpm-workspace.yaml, `workspaces` from package.json (npm, Yarn, Bun), or
1383
- * `packages` from lerna.json, in that order of precedence.
1534
+ * `packages` from lerna.json, in that order of precedence. A pnpm-workspace.yaml that names
1535
+ * no package beyond the root holds only settings, so the next source decides.
1384
1536
  */
1385
1537
  function workspacePatterns(dir) {
1386
1538
  const split = (list, source) => ({
@@ -1388,8 +1540,8 @@ function workspacePatterns(dir) {
1388
1540
  exclude: list.filter((p) => p.startsWith("!")).map((p) => toPosix(p.slice(1))),
1389
1541
  source
1390
1542
  });
1391
- const yaml = path.join(dir, "pnpm-workspace.yaml");
1392
- if (fs.existsSync(yaml)) return split(pnpmPackages(fs.readFileSync(yaml, "utf8")), "pnpm-workspace.yaml");
1543
+ const pnpm = pnpmWorkspacePackages(dir);
1544
+ if (pnpm.length) return split(pnpm, "pnpm-workspace.yaml");
1393
1545
  const workspaces = readJson$1(path.join(dir, "package.json"))?.workspaces;
1394
1546
  const list = Array.isArray(workspaces) ? workspaces : asRecord(workspaces).packages;
1395
1547
  if (Array.isArray(list)) return split(list.filter((p) => typeof p === "string"), "package.json workspaces");
@@ -1428,12 +1580,13 @@ function workspacePackageDirs(root, nx = true) {
1428
1580
  }
1429
1581
  /**
1430
1582
  * The `packages` list of a pnpm-workspace.yaml, block (`- apps/*`, indented or
1431
- * not) or flow (`[apps/*, packages/*]`) style, with comments. Only that key is
1432
- * read, so this stays a few lines rather than a YAML parser.
1583
+ * not) or flow (`[apps/*, packages/*]`) style, with comments, a quoted key or a
1584
+ * leading byte order mark. Only that key is read, so this stays a few lines
1585
+ * rather than a YAML parser.
1433
1586
  */
1434
1587
  function pnpmPackages(text) {
1435
- const lines = text.split(/\r?\n/);
1436
- const start = lines.findIndex((line) => /^packages\s*:/.test(line));
1588
+ const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
1589
+ const start = lines.findIndex((line) => /^(['"]?)packages\1\s*:/.test(line));
1437
1590
  if (start === -1) return [];
1438
1591
  const uncomment = (s) => s.replace(/(?:^|\s)#.*$/, "").trim();
1439
1592
  const unquote = (s) => s.trim().replace(/^(['"])(.*)\1$/, "$2");
@@ -1506,9 +1659,21 @@ function isBareHsl(value) {
1506
1659
  return BARE_HSL.test(value.trim());
1507
1660
  }
1508
1661
  /**
1662
+ * RGB channels without the function, `22 163 74` (commas and `/ alpha` allowed): how
1663
+ * Tailwind v3's docs store colors, to be used as `rgb(var(--success) / <alpha-value>)`.
1664
+ * Three numbers alone are no sure color, so they count as one only in a token already
1665
+ * known to be a color: one the Tailwind config reads with `rgb()`.
1666
+ */
1667
+ const BARE_RGB = /^(\d{1,3})(?:\s*,\s*|\s+)(\d{1,3})(?:\s*,\s*|\s+)(\d{1,3})(?:\s*\/\s*(\d*\.?\d+%?))?$/;
1668
+ function isBareRgb(value) {
1669
+ const match = BARE_RGB.exec(value.trim());
1670
+ return match !== null && match.slice(1, 4).every((channel) => Number(channel) <= 255);
1671
+ }
1672
+ /**
1509
1673
  * Parses any CSS color (hex, rgb(), hsl(), oklch(), named, ...) into OKLCH.
1510
1674
  * With `bareHsl`, for token values, HSL channels count too: in a stylesheet
1511
1675
  * they can only be a color, while in a class or style they are not one.
1676
+ * With `bareRgb`, for the values of color tokens, RGB channels count too.
1512
1677
  */
1513
1678
  function parseColor(value, options = {}) {
1514
1679
  const text = value.trim();
@@ -1519,10 +1684,27 @@ function parseColor(value, options = {}) {
1519
1684
  const [, h, deg = "", s, l, alpha] = hsl;
1520
1685
  parsed = parse(`hsl(${h}${deg} ${s}% ${l}%${alpha ? ` / ${alpha}` : ""})`);
1521
1686
  }
1687
+ const rgb = !parsed && options.bareRgb && isBareRgb(text) ? BARE_RGB.exec(text) : null;
1688
+ if (rgb) {
1689
+ const [, r, g, b, alpha] = rgb;
1690
+ parsed = parse(`rgb(${r} ${g} ${b}${alpha ? ` / ${alpha}` : ""})`);
1691
+ }
1522
1692
  return parsed ? toOklch$1(parsed) : void 0;
1523
1693
  }
1694
+ /** A color's opacity, from 0 (transparent) to 1 (opaque). */
1695
+ function opacity(color) {
1696
+ return color.alpha ?? 1;
1697
+ }
1698
+ /**
1699
+ * How much a difference in opacity weighs against ΔE, which leaves it out. Translucency is
1700
+ * part of what a color does (an overlay, a tint, a frosted header), so a token more than
1701
+ * 0.02 apart in opacity is never close (5 × 0.02 is CLOSE_COLOR): `bg-[#e7000b33]`, the red
1702
+ * at 20%, is not `bg-destructive`. One step of an 8-digit hex (1/255) is still the same color.
1703
+ */
1704
+ const OPACITY_WEIGHT = 5;
1705
+ /** ΔE between two colors, plus their difference in opacity. */
1524
1706
  function colorDistance(a, b) {
1525
- return okDistance(a, b);
1707
+ return okDistance(a, b) + OPACITY_WEIGHT * Math.abs(opacity(a) - opacity(b));
1526
1708
  }
1527
1709
  function isNamedColor(value) {
1528
1710
  return Object.hasOwn(colorsNamed, value.toLowerCase());
@@ -1602,6 +1784,25 @@ function sameHue(color, token, tinted = isTinted(color), tolerance = 15) {
1602
1784
  function formatDeltaE(deltaE) {
1603
1785
  return deltaE < 5e-4 ? "exact match" : `ΔE ${deltaE.toFixed(3)}`;
1604
1786
  }
1787
+ /** `opaque`, `20% opaque` or `transparent`, to the nearest percent. */
1788
+ function formatOpacity(color) {
1789
+ const percent = Math.round(opacity(color) * 100);
1790
+ return percent === 100 ? "opaque" : percent === 0 ? "transparent" : `${String(percent)}% opaque`;
1791
+ }
1792
+ /**
1793
+ * When a token's value (`token`) and a color differ in opacity, a sentence that says so:
1794
+ * `destructive is opaque, and this color is 20% opaque.`
1795
+ */
1796
+ function formatOpacityGap(color, token, name) {
1797
+ const [own, its] = [formatOpacity(color), formatOpacity(token)];
1798
+ return own === its ? void 0 : `${name} is ${its}, and this color is ${own}.`;
1799
+ }
1800
+ /** ΔE between a color and a token's value, which `formatOpacityGap` completes. */
1801
+ function formatDistance(color, token) {
1802
+ const deltaE = okDistance(color, token);
1803
+ if (deltaE < 5e-4 && formatOpacityGap(color, token, "")) return "same color";
1804
+ return formatDeltaE(deltaE);
1805
+ }
1605
1806
  //#endregion
1606
1807
  //#region src/libraries/mui.ts
1607
1808
  /** The palette groups MUI's components take as a `color` value (`<Button color="primary">`). */
@@ -1945,21 +2146,57 @@ function themeTokens(options, source) {
1945
2146
  const LIBRARY_IDS = ["mui"];
1946
2147
  /** The libraries onsystem can read, by package. */
1947
2148
  const LIBRARIES = [mui];
2149
+ /**
2150
+ * Published component libraries onsystem does not read yet, by package. A project that depends
2151
+ * on one is told so, since nothing in it is checked and a clean result would look like a
2152
+ * misconfigured one.
2153
+ */
2154
+ const UNREAD_LIBRARIES = [
2155
+ {
2156
+ package: "@chakra-ui/react",
2157
+ title: "Chakra UI"
2158
+ },
2159
+ {
2160
+ package: "@mantine/core",
2161
+ title: "Mantine"
2162
+ },
2163
+ {
2164
+ package: "@heroui/react",
2165
+ title: "HeroUI"
2166
+ },
2167
+ {
2168
+ package: "@radix-ui/themes",
2169
+ title: "Radix Themes"
2170
+ },
2171
+ {
2172
+ package: "antd",
2173
+ title: "Ant Design"
2174
+ }
2175
+ ];
1948
2176
  function libraryAdapter(name) {
1949
2177
  return LIBRARIES.find((library) => library.package === name);
1950
2178
  }
2179
+ /** A library's name for messages, read or not: `MUI`, `Chakra UI`. */
2180
+ function libraryTitle(name) {
2181
+ return [...LIBRARIES, ...UNREAD_LIBRARIES].find((l) => l.package === name)?.title ?? name;
2182
+ }
2183
+ /** Why a library in `UNREAD_LIBRARIES` is not read: `a dependency onsystem does not read yet (…)`. */
2184
+ function notReadYet() {
2185
+ return `a dependency onsystem does not read yet (it reads only ${LIBRARIES.map((l) => l.title).join(", ")} so far)`;
2186
+ }
1951
2187
  /**
1952
2188
  * The libraries `root`'s package.json depends on (dependencies, devDependencies or
1953
2189
  * peerDependencies), each found where Node.js would find it: in `node_modules` at the root or a
1954
2190
  * folder above it, as a monorepo hoists it. Nothing in the package is run. `ids` is the config's
1955
2191
  * `libraries`: undefined reads every library onsystem supports, false none, a list those named;
1956
- * the dependencies it leaves out are `off`.
2192
+ * the dependencies it leaves out are `off`. Those in `UNREAD_LIBRARIES` are `unread`.
1957
2193
  */
1958
2194
  function detectLibraries(root, ids) {
1959
2195
  const pkg = readJson(path.join(root, "package.json"));
1960
2196
  if (!pkg) return {
1961
2197
  read: [],
1962
- off: []
2198
+ off: [],
2199
+ unread: []
1963
2200
  };
1964
2201
  const dependencies = new Set([
1965
2202
  "dependencies",
@@ -1969,23 +2206,24 @@ function detectLibraries(root, ids) {
1969
2206
  const value = pkg[field];
1970
2207
  return value && typeof value === "object" ? Object.keys(value) : [];
1971
2208
  }));
1972
- const found = LIBRARIES.filter((library) => dependencies.has(library.package)).map((library) => {
1973
- const dir = installedPackage(root, library.package);
2209
+ const sourceOf = (name) => {
2210
+ const dir = installedPackage(root, name);
1974
2211
  const version = dir ? readJson(path.join(dir, "package.json"))?.version : void 0;
1975
- const source = {
1976
- package: library.package,
2212
+ return {
2213
+ package: name,
1977
2214
  dir,
1978
2215
  version: typeof version === "string" ? version : void 0
1979
2216
  };
1980
- return {
1981
- id: library.id,
1982
- source
1983
- };
1984
- });
2217
+ };
2218
+ const found = LIBRARIES.filter((library) => dependencies.has(library.package)).map((library) => ({
2219
+ id: library.id,
2220
+ source: sourceOf(library.package)
2221
+ }));
1985
2222
  const wanted = (id) => ids === void 0 || ids !== false && ids.includes(id);
1986
2223
  return {
1987
2224
  read: found.filter((f) => wanted(f.id)).map((f) => f.source),
1988
- off: found.filter((f) => !wanted(f.id)).map((f) => f.source)
2225
+ off: found.filter((f) => !wanted(f.id)).map((f) => f.source),
2226
+ unread: UNREAD_LIBRARIES.filter((library) => dependencies.has(library.package)).map((library) => sourceOf(library.package))
1989
2227
  };
1990
2228
  }
1991
2229
  /** The folder `name` is installed in for code in `root`, looking up through `node_modules` folders. */
@@ -2249,12 +2487,15 @@ function usageFor(token, tailwindNamespace) {
2249
2487
  }
2250
2488
  /**
2251
2489
  * The CSS value that uses the token: `var(--primary)`, or `hsl(var(--primary))`
2252
- * when the property holds bare HSL channels (Tailwind v3), which are no color
2253
- * on their own.
2490
+ * (`rgb(var(--success))`) when the property holds bare HSL (RGB) channels
2491
+ * (Tailwind v3), which are no color on their own.
2254
2492
  */
2255
2493
  function cssReference(token) {
2256
2494
  if (!token.cssVar) return void 0;
2257
- return token.category === "color" && isBareHsl(token.value) ? `hsl(var(${token.cssVar}))` : `var(${token.cssVar})`;
2495
+ if (token.category !== "color") return `var(${token.cssVar})`;
2496
+ if (isBareHsl(token.value)) return `hsl(var(${token.cssVar}))`;
2497
+ if (isBareRgb(token.value)) return `rgb(var(${token.cssVar}))`;
2498
+ return `var(${token.cssVar})`;
2258
2499
  }
2259
2500
  function utilities(namespace, key) {
2260
2501
  switch (namespace) {
@@ -2301,6 +2542,7 @@ const MAX_FILES = 12;
2301
2542
  */
2302
2543
  function readTailwindColors(file, resolve) {
2303
2544
  const colors = /* @__PURE__ */ new Map();
2545
+ const rgb = /* @__PURE__ */ new Set();
2304
2546
  const files = [];
2305
2547
  const queue = [file];
2306
2548
  while (queue.length && files.length < MAX_FILES) {
@@ -2316,7 +2558,7 @@ function readTailwindColors(file, resolve) {
2316
2558
  const source = ts.createSourceFile(current, text, ts.ScriptTarget.Latest, true, scriptKind(current));
2317
2559
  const visit = (node) => {
2318
2560
  if (ts.isPropertyAssignment(node) && propertyName(node.name) === "colors" && ts.isObjectLiteralExpression(node.initializer)) {
2319
- collect(node.initializer, [], colors);
2561
+ collect(node.initializer, [], colors, rgb);
2320
2562
  return;
2321
2563
  }
2322
2564
  const specifier = importedSpecifier(node);
@@ -2328,11 +2570,12 @@ function readTailwindColors(file, resolve) {
2328
2570
  }
2329
2571
  return {
2330
2572
  colors,
2573
+ rgb,
2331
2574
  files
2332
2575
  };
2333
2576
  }
2334
2577
  /** `primary: { DEFAULT: "hsl(var(--primary))", foreground: … }` → `--primary` → `primary`, `--primary-foreground` → `primary-foreground`. */
2335
- function collect(object, keys, colors) {
2578
+ function collect(object, keys, colors, rgb) {
2336
2579
  for (const property of object.properties) {
2337
2580
  if (!ts.isPropertyAssignment(property)) continue;
2338
2581
  const key = propertyName(property.name);
@@ -2340,13 +2583,15 @@ function collect(object, keys, colors) {
2340
2583
  const value = property.initializer;
2341
2584
  const nested = [...keys, key];
2342
2585
  if (ts.isObjectLiteralExpression(value)) {
2343
- collect(value, nested, colors);
2586
+ collect(value, nested, colors, rgb);
2344
2587
  continue;
2345
2588
  }
2346
2589
  if (!ts.isStringLiteralLike(value)) continue;
2347
2590
  const variable = /var\(\s*(--[\w-]+)/.exec(value.text)?.[1];
2348
2591
  const name = nested.filter((k) => k !== "DEFAULT").join("-");
2349
- if (variable && name && !colors.has(variable)) colors.set(variable, name);
2592
+ if (!variable || !name || colors.has(variable)) continue;
2593
+ colors.set(variable, name);
2594
+ if (/^\s*rgba?\(\s*var\(/.test(value.text)) rgb.add(variable);
2350
2595
  }
2351
2596
  }
2352
2597
  /**
@@ -2449,9 +2694,9 @@ function applyTailwindRadii(tokens, radii, configFile = "tailwind.config") {
2449
2694
  havePx.add(px);
2450
2695
  }
2451
2696
  }
2452
- /** The specifier of `require("x")`, `import x from "x"` or `import("x")`. */
2697
+ /** The specifier of `require("x")`, `import x from "x"`, `export { default } from "x"` or `import("x")`. */
2453
2698
  function importedSpecifier(node) {
2454
- if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) return node.moduleSpecifier.text;
2699
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) return node.moduleSpecifier.text;
2455
2700
  if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require") && node.arguments[0] && ts.isStringLiteralLike(node.arguments[0])) return node.arguments[0].text;
2456
2701
  }
2457
2702
  function scriptKind(file) {
@@ -2500,9 +2745,13 @@ const SHADCN_COLORS = /* @__PURE__ */ new Set([
2500
2745
  * Gives color tokens their Tailwind v3 class names: the key the config maps
2501
2746
  * the custom property to, or, when the config has no colors we can read,
2502
2747
  * shadcn/ui's convention. Tokens that already have a key (`@theme`) keep it.
2748
+ * A property the config reads as RGB channels (`rgb(var(--success) / <alpha-value>)`)
2749
+ * is a color token when its values are such channels (`22 163 74`), which the
2750
+ * stylesheet alone leaves undecided.
2503
2751
  */
2504
- function applyTailwindColors(tokens, colors) {
2752
+ function applyTailwindColors(tokens, colors, rgb = /* @__PURE__ */ new Set()) {
2505
2753
  for (const token of tokens) {
2754
+ if (token.category === "other" && token.cssVar && rgb.has(token.cssVar) && [token.value, ...Object.values(token.modes ?? {})].every(isBareRgb)) token.category = "color";
2506
2755
  if (token.category !== "color" || token.tailwind !== void 0 || !token.cssVar) continue;
2507
2756
  const name = token.cssVar.slice(2);
2508
2757
  const key = colors.size ? colors.get(token.cssVar) : SHADCN_COLORS.has(name) ? name.replace(/^sidebar-background$/, "sidebar") : void 0;
@@ -2511,6 +2760,49 @@ function applyTailwindColors(tokens, colors) {
2511
2760
  token.usage = usageFor(token, "--color");
2512
2761
  }
2513
2762
  }
2763
+ /** Enough of a package's stylesheets to find its theme, without reading a whole design system. */
2764
+ const MAX_PRESET_STYLESHEETS = 200;
2765
+ /**
2766
+ * The stylesheets that set the custom properties a Tailwind v3 config maps to colors, when
2767
+ * they sit in a workspace package the config reads its presets from rather than in the
2768
+ * project: Dub's `@dub/tailwind-config`, whose `themes.css` sets `--content-error` for the
2769
+ * `content-error` its config maps. Each CSS file of such a package (outside `node_modules`
2770
+ * and build output) that declares one of them, in path order. The project's own folder, and
2771
+ * a folder that holds it (a preset at the workspace root), are not searched.
2772
+ */
2773
+ function presetStylesheets(read, root) {
2774
+ if (!read.colors.size) return [];
2775
+ const packages = /* @__PURE__ */ new Set();
2776
+ for (const file of read.files) {
2777
+ const dir = packageDir(path.dirname(file));
2778
+ if (dir && !isInside(dir, root)) packages.add(dir);
2779
+ }
2780
+ const out = [];
2781
+ for (const dir of packages) {
2782
+ const sheets = globSync("**/*.css", {
2783
+ cwd: dir,
2784
+ absolute: true,
2785
+ ignore: ["**/node_modules/**", "**/{dist,build,out,.next,.turbo,coverage}/**"]
2786
+ }).sort().slice(0, MAX_PRESET_STYLESHEETS);
2787
+ for (const sheet of sheets) {
2788
+ let text;
2789
+ try {
2790
+ text = fs.readFileSync(sheet, "utf8");
2791
+ } catch {
2792
+ continue;
2793
+ }
2794
+ if ([...text.matchAll(/(--[\w-]+)\s*:/g)].some((m) => read.colors.has(m[1] ?? ""))) out.push(path.resolve(sheet));
2795
+ }
2796
+ }
2797
+ return out;
2798
+ }
2799
+ /** The nearest folder at or above `dir` with a package.json. */
2800
+ function packageDir(dir) {
2801
+ for (let current = dir;; current = path.dirname(current)) {
2802
+ if (fs.existsSync(path.join(current, "package.json"))) return current;
2803
+ if (current === path.dirname(current)) return void 0;
2804
+ }
2805
+ }
2514
2806
  /** The project's Tailwind v3 config: `tailwind.config` in components.json, else `tailwind.config.*` in the root. */
2515
2807
  function findTailwindConfig(root) {
2516
2808
  try {
@@ -2767,6 +3059,7 @@ async function loadConfigWith(options, context = {}) {
2767
3059
  ...t,
2768
3060
  path: slashGlob(t.path)
2769
3061
  })),
3062
+ ...!options.tokens?.length && configTokens === void 0 ? { tokensDetected: true } : {},
2770
3063
  docs: (options.docs?.length ? options.docs : configDocs === void 0 ? [.../* @__PURE__ */ new Set([...DEFAULT_DOCS, ...detection?.docs ?? []])] : configDocs.map(fromBase)).map(slashGlob),
2771
3064
  tsconfig,
2772
3065
  importPath: config.importPath,
@@ -2777,6 +3070,7 @@ async function loadConfigWith(options, context = {}) {
2777
3070
  libraries: libraries.read,
2778
3071
  libraryIds: config.libraries,
2779
3072
  librariesOff: libraries.off,
3073
+ librariesUnread: libraries.unread,
2780
3074
  elements: config.elements ?? {},
2781
3075
  includeDesignSystem: options.includeDesignSystem ?? (declared ? void 0 : config.includeDesignSystem) ?? false,
2782
3076
  rules,
@@ -3118,8 +3412,14 @@ function buildComponent$1(context, candidate) {
3118
3412
  const definitions = [...primary, ...nested];
3119
3413
  const { props, inherits, openProps } = extractProps(context, candidate, typeNode, param);
3120
3414
  const own = new Set(primary.flatMap((d) => d.variants.map((v) => v.name)));
3121
- const variants = mergeVariants([...primary.flatMap((d) => d.variants), ...nested.flatMap((d) => d.variants).filter((v) => !own.has(v.name) && props.some((p) => p.name === v.name))]);
3122
- mergeVariantProps(props, variants);
3415
+ const adds = (name) => !own.has(name) && props.some((p) => p.name === name);
3416
+ const variants = mergeVariants([...primary.flatMap((d) => d.variants), ...nested.flatMap((d) => d.variants).filter((v) => adds(v.name))]);
3417
+ const layouts = /* @__PURE__ */ new Map();
3418
+ for (const definition of definitions) for (const [name, layout] of variantLayouts(definition)) {
3419
+ if (!primary.includes(definition) && !adds(name)) continue;
3420
+ layouts.set(name, [...layouts.get(name) ?? [], ...layout]);
3421
+ }
3422
+ mergeVariantProps(props, variants, layouts);
3123
3423
  applyDestructuredDefaults(props, param);
3124
3424
  const position = sourceFile.getLineAndCharacterOfPosition(candidate.declaration.getStart());
3125
3425
  const info = {
@@ -3280,10 +3580,11 @@ function extractProps(context, candidate, typeNode, param) {
3280
3580
  if (checker.getIndexInfosOfType(t).length) openProps = true;
3281
3581
  for (const symbol of checker.getPropertiesOfType(t)) {
3282
3582
  const entry = own.get(symbol.getName()) ?? {
3283
- symbol,
3583
+ symbols: [],
3284
3584
  requiredIn: 0,
3285
3585
  origin: originOf(symbol)
3286
3586
  };
3587
+ if (!entry.symbols.length || type.isUnion() && !entry.symbols.includes(symbol)) entry.symbols.push(symbol);
3287
3588
  if (!(symbol.flags & ts.SymbolFlags.Optional)) entry.requiredIn++;
3288
3589
  own.set(symbol.getName(), entry);
3289
3590
  }
@@ -3292,9 +3593,9 @@ function extractProps(context, candidate, typeNode, param) {
3292
3593
  for (const [name, { origin }] of own) byOrigin.set(origin, [...byOrigin.get(origin) ?? [], name]);
3293
3594
  const summarised = new Set([...byOrigin].filter(([origin, names]) => origin === "react" || origin !== "own" && names.length > MAX_LISTED_INHERITED$1).map(([origin]) => origin));
3294
3595
  const props = [];
3295
- for (const [name, { symbol, requiredIn, origin }] of own) {
3596
+ for (const [name, { symbols, requiredIn, origin }] of own) {
3296
3597
  if (summarised.has(origin)) continue;
3297
- props.push(describeProp$1(context, name, symbol, location, requiredIn === types.length, origin));
3598
+ props.push(describeProp$1(context, name, symbols, location, requiredIn === types.length, origin));
3298
3599
  }
3299
3600
  const inherits = [];
3300
3601
  for (const origin of summarised) {
@@ -3302,7 +3603,7 @@ function extractProps(context, candidate, typeNode, param) {
3302
3603
  const key = registerPropSet(context.propSets, names);
3303
3604
  const from = origin === "react" ? inheritedLabel(context, typeNode) : origin;
3304
3605
  const deprecated = names.filter((name) => {
3305
- return (own.get(name)?.symbol)?.getJsDocTags(checker).some((tag) => tag.name === "deprecated");
3606
+ return (own.get(name)?.symbols[0])?.getJsDocTags(checker).some((tag) => tag.name === "deprecated");
3306
3607
  });
3307
3608
  inherits.push({
3308
3609
  from,
@@ -3374,21 +3675,35 @@ function originOf(symbol) {
3374
3675
  if (pkg === "@types/react" || pkg === "typescript") return "react";
3375
3676
  return pkg;
3376
3677
  }
3377
- function describeProp$1(context, name, symbol, location, required, origin) {
3678
+ /**
3679
+ * A prop that several members of a union declare takes what any of them allows: its type is
3680
+ * the union of theirs, and so are its values when each declares a set of string literals.
3681
+ */
3682
+ function describeProp$1(context, name, symbols, location, required, origin) {
3378
3683
  const { checker } = context;
3379
- const declared = checker.getTypeOfSymbolAtLocation(symbol, location);
3380
- const type = checker.getNonNullableType(declared);
3684
+ const allows = (symbol) => !(checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(symbol, location)).flags & ts.TypeFlags.Never);
3685
+ const members = symbols.some(allows) ? symbols.filter(allows) : symbols;
3686
+ const printed = /* @__PURE__ */ new Map();
3687
+ let values = [];
3688
+ for (const symbol of members) {
3689
+ const declared = checker.getTypeOfSymbolAtLocation(symbol, location);
3690
+ const type = checker.getNonNullableType(declared);
3691
+ const text = unwrapParens(checker.typeToString(declared, void 0, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope).replace(/^(?:(?:undefined|null) \| )+|(?: \| (?:undefined|null))+$/g, ""));
3692
+ if (text) printed.set(text, !type.isUnion() && (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0));
3693
+ const found = literalValues$2(type);
3694
+ values = found && values ? [...values, ...found] : void 0;
3695
+ }
3381
3696
  const prop = {
3382
3697
  name,
3383
- type: truncate(unwrapParens(checker.typeToString(declared, void 0, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope).replace(/^(?:(?:undefined|null) \| )+|(?: \| (?:undefined|null))+$/g, "")) || "unknown", 200),
3698
+ type: truncate([...printed].map(([text, fn]) => fn && printed.size > 1 ? `(${text})` : text).join(" | ") || "unknown", 200),
3384
3699
  required,
3385
3700
  kind: "prop"
3386
3701
  };
3387
- const values = literalValues$2(type);
3388
- if (values) prop.values = values;
3389
- const description = ts.displayPartsToString(symbol.getDocumentationComment(checker)).trim();
3702
+ if (values) prop.values = unique(values);
3703
+ const documented = members.find((s) => s.getDocumentationComment(checker).length) ?? members[0];
3704
+ const description = documented ? ts.displayPartsToString(documented.getDocumentationComment(checker)).trim() : "";
3390
3705
  if (description) prop.description = description;
3391
- for (const tag of symbol.getJsDocTags(checker)) {
3706
+ for (const tag of documented?.getJsDocTags(checker) ?? []) {
3392
3707
  const text = ts.displayPartsToString(tag.text).trim();
3393
3708
  if ((tag.name === "default" || tag.name === "defaultValue") && text) prop.default = text;
3394
3709
  if (tag.name === "deprecated") prop.deprecated = text || true;
@@ -3482,11 +3797,47 @@ function mergeVariants(variants) {
3482
3797
  }
3483
3798
  return [...merged.values()];
3484
3799
  }
3485
- /** Adds or enriches variant props from cva/tv definitions (works without type info). */
3486
- function mergeVariantProps(props, variants) {
3800
+ /** The layout of each variant a cva/tv definition declares, read as `parseDefinition` reads it. */
3801
+ function variantLayouts(definition) {
3802
+ const layouts = /* @__PURE__ */ new Map();
3803
+ const [first, second] = definition.node.arguments;
3804
+ const config = second ?? first;
3805
+ if (!config || !ts.isObjectLiteralExpression(config)) return layouts;
3806
+ const named = (name) => config.properties.find((p) => !ts.isSpreadAssignment(p) && propertyName(p.name) === name);
3807
+ const variants = named("variants");
3808
+ if (!variants || !ts.isPropertyAssignment(variants)) return layouts;
3809
+ if (!ts.isObjectLiteralExpression(variants.initializer)) return layouts;
3810
+ const extended = named("extend") !== void 0;
3811
+ for (const prop of variants.initializer.properties) {
3812
+ if (!ts.isPropertyAssignment(prop) || !ts.isObjectLiteralExpression(prop.initializer)) continue;
3813
+ const name = propertyName(prop.name);
3814
+ if (name === void 0) continue;
3815
+ const layout = extended ? [void 0] : [];
3816
+ for (const option of prop.initializer.properties) layout.push(ts.isPropertyAssignment(option) ? propertyName(option.name) : void 0);
3817
+ layouts.set(name, layout);
3818
+ }
3819
+ return layouts;
3820
+ }
3821
+ /** `values` in a layout's order, those it does not list where it takes them (or last). */
3822
+ function ordered(values, layout) {
3823
+ const unlisted = values.filter((v) => !layout.includes(v));
3824
+ const placed = layout.flatMap((key) => key === void 0 ? unlisted : values.includes(key) ? [key] : []);
3825
+ return unique(layout.includes(void 0) ? placed : [...placed, ...unlisted]);
3826
+ }
3827
+ /**
3828
+ * Adds or enriches variant props from cva/tv definitions (works without type info). A prop the
3829
+ * checker types as a set of strings keeps that set: the literal can take values it does not list
3830
+ * (`{ ...base, brand }`, tv's `extend`) and only gives their order. Without one, the literal's
3831
+ * keys are the values, unless it does not list them all: then the prop takes any value (or, when
3832
+ * the keys it lists are `true`/`false`, stays a boolean).
3833
+ */
3834
+ function mergeVariantProps(props, variants, layouts) {
3487
3835
  for (const variant of variants) {
3488
- const isBoolean = variant.values.every((v) => v === "true" || v === "false");
3836
+ const layout = layouts.get(variant.name) ?? variant.values;
3837
+ const open = layout.includes(void 0);
3838
+ const isBoolean = (variant.values.length > 0 || !open) && variant.values.every((v) => v === "true" || v === "false");
3489
3839
  const existing = props.find((p) => p.name === variant.name);
3840
+ const typed = existing?.values;
3490
3841
  const prop = existing ?? {
3491
3842
  name: variant.name,
3492
3843
  type: isBoolean ? "boolean" : variant.values.map((v) => JSON.stringify(v)).join(" | "),
@@ -3494,7 +3845,13 @@ function mergeVariantProps(props, variants) {
3494
3845
  kind: "variant"
3495
3846
  };
3496
3847
  prop.kind = "variant";
3497
- if (!isBoolean) {
3848
+ if (typed?.length) {
3849
+ prop.values = ordered(typed, layout);
3850
+ prop.type = prop.values.map((v) => JSON.stringify(v)).join(" | ");
3851
+ variant.values = [...prop.values];
3852
+ } else if (open && !isBoolean) {
3853
+ if (!existing) prop.type = "string";
3854
+ } else if (!isBoolean) {
3498
3855
  prop.values = [...variant.values];
3499
3856
  prop.type = variant.values.map((v) => JSON.stringify(v)).join(" | ");
3500
3857
  }
@@ -4583,7 +4940,7 @@ function resolveElement(element, analysis, target, file) {
4583
4940
  const [head = "", ...rest] = tag.split(".");
4584
4941
  const binding = analysis.imports.get(head);
4585
4942
  if (binding?.imported === "*" && rest.length) {
4586
- if (!target.isDesignSystemImport(binding.source, file)) return { kind: "external" };
4943
+ if (!target.checksImport(binding.source, file)) return { kind: "external" };
4587
4944
  const name = rest.join(".");
4588
4945
  const library = target.libraryOf(binding.source);
4589
4946
  if (library) return fromLibrary(target, library, name, binding.source, rest.length > 1);
@@ -4610,7 +4967,7 @@ function resolveElement(element, analysis, target, file) {
4610
4967
  if (resolved.kind !== "component") return resolved;
4611
4968
  owner = resolved.component;
4612
4969
  } else if (binding) {
4613
- if (!target.isDesignSystemImport(binding.source, file)) return { kind: "external" };
4970
+ if (!target.checksImport(binding.source, file)) return { kind: "external" };
4614
4971
  const imported = binding.imported === "default" ? target.defaultExport(binding.source, file)?.name ?? head : binding.imported;
4615
4972
  owner = target.componentFor(imported, binding.source, file);
4616
4973
  if (!owner && target.components.has(imported)) return { kind: "external" };
@@ -5596,7 +5953,7 @@ function parseDtcgTokens(text, file, options = {}) {
5596
5953
  const type = typeOf(t);
5597
5954
  const format = (value) => {
5598
5955
  const resolved = resolve(value, /* @__PURE__ */ new Set([path]));
5599
- return t.own?.channels === "hsl" && hslChannels(resolved) || formatValue(resolved, type);
5956
+ return t.own?.channels === "hsl" && hslChannels(resolved) || t.own?.channels === "rgb" && rgbChannels(resolved) || formatValue(resolved, type);
5600
5957
  };
5601
5958
  const segments = [options.prefix, ...t.path.filter((p) => p !== "$root")].filter(Boolean);
5602
5959
  const generated = segments.length ? `--${segments.join("-").replace(/[^\w-]+/g, "-")}` : void 0;
@@ -5751,7 +6108,7 @@ function onsystemExtension(extensions) {
5751
6108
  if (typeof own.name === "string" && /^[^\s{}]+$/.test(own.name)) out.name = own.name;
5752
6109
  if (typeof own.cssVar === "string" && /^--[\w-]+$/.test(own.cssVar)) out.cssVar = own.cssVar;
5753
6110
  if (typeof own.tailwind === "string" && themeVariable(own.tailwind)) out.tailwind = own.tailwind;
5754
- if (own.channels === "hsl") out.channels = "hsl";
6111
+ if (own.channels === "hsl" || own.channels === "rgb") out.channels = own.channels;
5755
6112
  if (isObject$2(own.modes)) out.modes = own.modes;
5756
6113
  return out;
5757
6114
  }
@@ -5791,6 +6148,13 @@ function hslChannels(value) {
5791
6148
  if (h === void 0 || s === void 0 || l === void 0) return void 0;
5792
6149
  return `${h} ${s}% ${l}%${typeof value.alpha === "number" && value.alpha < 1 ? ` / ${value.alpha}` : ""}`;
5793
6150
  }
6151
+ /** `22 163 74`: an sRGB color as the bare channels a Tailwind v3 custom property holds. */
6152
+ function rgbChannels(value) {
6153
+ if (!isObject$2(value) || value.colorSpace !== "srgb" || !Array.isArray(value.components)) return;
6154
+ const [r, g, b] = value.components.map((c) => typeof c === "number" ? String(Math.round(c * 255)) : void 0);
6155
+ if (r === void 0 || g === void 0 || b === void 0) return void 0;
6156
+ return `${r} ${g} ${b}${typeof value.alpha === "number" && value.alpha < 1 ? ` / ${value.alpha}` : ""}`;
6157
+ }
5794
6158
  function formatValue(value, type) {
5795
6159
  if (typeof value === "string") return value;
5796
6160
  if (typeof value === "number" || typeof value === "boolean") return String(value);
@@ -5974,6 +6338,24 @@ function isStatusToken(token) {
5974
6338
  function tokenKey(token) {
5975
6339
  return (token.tailwind || token.name).toLowerCase().replace(/[.\s_]+/g, "-").replace(/^colou?rs?-/, "");
5976
6340
  }
6341
+ /** Name segments of a line color: `card-border`, `input`, `ring`. */
6342
+ const LINE_WORDS = [
6343
+ "border",
6344
+ "input",
6345
+ "ring",
6346
+ "outline",
6347
+ "divider",
6348
+ "separator"
6349
+ ];
6350
+ /** Name segments of a surface itself, as opposed to `muted`, which also describes text. */
6351
+ const SURFACE_NOUNS = [
6352
+ "background",
6353
+ "bg",
6354
+ "surface",
6355
+ "canvas",
6356
+ "card",
6357
+ "popover"
6358
+ ];
5977
6359
  /** Name segments that say what a token is for, checked in this order (`card-border` is a border). */
5978
6360
  const KIND_WORDS = [
5979
6361
  ["foreground", [
@@ -5981,21 +6363,9 @@ const KIND_WORDS = [
5981
6363
  "fg",
5982
6364
  "text"
5983
6365
  ]],
5984
- ["line", [
5985
- "border",
5986
- "input",
5987
- "ring",
5988
- "outline",
5989
- "divider",
5990
- "separator"
5991
- ]],
6366
+ ["line", LINE_WORDS],
5992
6367
  ["surface", [
5993
- "background",
5994
- "bg",
5995
- "surface",
5996
- "canvas",
5997
- "card",
5998
- "popover",
6368
+ ...SURFACE_NOUNS,
5999
6369
  "muted",
6000
6370
  "secondary",
6001
6371
  "accent"
@@ -6014,9 +6384,17 @@ function roleKey(token) {
6014
6384
  /** What a token is for, from its name: shadcn/ui's conventions and common DTCG names. */
6015
6385
  function kindOf(key) {
6016
6386
  const segments = key.split("-");
6387
+ if (segments[0] === "on") return "foreground";
6388
+ const namesLineOrSurface = (s) => LINE_WORDS.includes(s) || SURFACE_NOUNS.includes(s);
6389
+ if (segments[0] === "content" && !segments.some(namesLineOrSurface)) return "foreground";
6017
6390
  for (const [kind, words] of KIND_WORDS) if (segments.some((s) => words.includes(s))) return kind;
6018
6391
  return "other";
6019
6392
  }
6393
+ /** The role a token is made for, from its name (`text` for `content-muted`); undefined for `brand` or `destructive`. */
6394
+ function tokenRole(token) {
6395
+ const kind = kindOf(roleKey(token));
6396
+ return kind === "foreground" ? "text" : kind === "other" ? void 0 : kind;
6397
+ }
6020
6398
  /** Tokens made for each role, in the order to prefer them when their values tie. */
6021
6399
  const PREFERRED = {
6022
6400
  text: ["foreground", "muted-foreground"],
@@ -6137,7 +6515,7 @@ function toDtcg(tokens) {
6137
6515
  if (token.cssVar) extension.cssVar = token.cssVar;
6138
6516
  const tailwind = tailwindVariable(token);
6139
6517
  if (tailwind) extension.tailwind = tailwind;
6140
- if (own.channels) extension.channels = "hsl";
6518
+ if (own.channels) extension.channels = own.channels;
6141
6519
  if (token.modes && Object.keys(token.modes).length) extension.modes = Object.fromEntries(Object.entries(token.modes).map(([mode, value]) => [mode, typed(value, token, lookup).value]));
6142
6520
  if (Object.keys(extension).length) node.$extensions = { [ONSYSTEM_EXTENSION]: extension };
6143
6521
  insert(tree, path, node);
@@ -6259,14 +6637,17 @@ function typed(value, token, lookup) {
6259
6637
  }
6260
6638
  }
6261
6639
  function colorValue(value) {
6262
- if (!parseColor(value, { bareHsl: true })) return { value };
6263
- const bare = isBareHsl(value);
6264
- const color = colorObject(bare ? `hsl(${value.replace(/,/g, " ")})` : value);
6640
+ if (!parseColor(value, {
6641
+ bareHsl: true,
6642
+ bareRgb: true
6643
+ })) return { value };
6644
+ const channels = isBareHsl(value) ? "hsl" : isBareRgb(value) ? "rgb" : void 0;
6645
+ const color = colorObject(channels ? `${channels}(${value.replace(/,/g, " ")})` : value);
6265
6646
  if (!color) return { value };
6266
- return bare ? {
6647
+ return channels ? {
6267
6648
  type: "color",
6268
6649
  value: color,
6269
- channels: true
6650
+ channels
6270
6651
  } : {
6271
6652
  type: "color",
6272
6653
  value: color
@@ -6705,8 +7086,11 @@ function frontMatterValue(token, key, lookup) {
6705
7086
  const value = token.value.trim();
6706
7087
  switch (key) {
6707
7088
  case "colors":
6708
- if (!parseColor(value, { bareHsl: true })) return void 0;
6709
- return isBareHsl(value) ? `hsl(${value})` : value;
7089
+ if (!parseColor(value, {
7090
+ bareHsl: true,
7091
+ bareRgb: true
7092
+ })) return void 0;
7093
+ return isBareHsl(value) ? `hsl(${value})` : isBareRgb(value) ? `rgb(${value})` : value;
6710
7094
  case "rounded":
6711
7095
  case "spacing": {
6712
7096
  if (DESIGN_DIMENSION.test(value) || value === "0") return value;
@@ -6870,6 +7254,11 @@ const ROUNDED_FULL = {
6870
7254
  line: 0
6871
7255
  }
6872
7256
  };
7257
+ /** A color token's value: bare HSL or RGB channels are its color (Tailwind v3). */
7258
+ const TOKEN_COLOR = {
7259
+ bareHsl: true,
7260
+ bareRgb: true
7261
+ };
6873
7262
  /** Query helpers over the token list: lookups and nearest-token search. */
6874
7263
  var TokenIndex = class {
6875
7264
  tokens;
@@ -6899,7 +7288,7 @@ var TokenIndex = class {
6899
7288
  const resolve = (name) => this.byVar.get(name)?.value;
6900
7289
  let unit;
6901
7290
  for (const token of tokens) if (token.category === "color") {
6902
- const color = parseColor(token.value, { bareHsl: true });
7291
+ const color = parseColor(token.value, TOKEN_COLOR);
6903
7292
  if (color) this.colors.push({
6904
7293
  token,
6905
7294
  color,
@@ -6988,20 +7377,38 @@ var TokenIndex = class {
6988
7377
  candidate: named.candidate,
6989
7378
  distance: named.distance
6990
7379
  };
6991
- if (match) return {
6992
- match: nearest,
6993
- nearest,
6994
- sameValue
6995
- };
7380
+ if (match) {
7381
+ const status = match.status ? void 0 : pool.filter((s) => s.status && s.looseHue && tinted && !s.foreign && s.fit.tier < 2 && s.distance < .1 && s.distance - match.distance < .03).sort(byDistance)[0];
7382
+ return {
7383
+ match: nearest,
7384
+ nearest,
7385
+ sameValue,
7386
+ closeStatus: status && {
7387
+ candidate: status.candidate,
7388
+ distance: status.distance
7389
+ }
7390
+ };
7391
+ }
6996
7392
  if (!named.hue && named.status && named.looseHue && tinted) return {
6997
7393
  nearest,
6998
7394
  reason: "status",
6999
7395
  hueOff: Math.round(hueDistance(color, valueOf(named.candidate))),
7000
7396
  sameValue
7001
7397
  };
7398
+ const other = pool.filter((s) => s.hue && !s.foreign && s.fit.tier === 2 && s.distance < .1).sort(byDistance)[0];
7399
+ const reason = !named.hue ? "hue" : named.distance >= .1 ? other ? "role" : "far" : named.foreign ? "scoped" : "role";
7400
+ if (reason === "role" && other && other !== named) return {
7401
+ nearest,
7402
+ reason,
7403
+ otherRole: {
7404
+ candidate: other.candidate,
7405
+ distance: other.distance
7406
+ },
7407
+ sameValue
7408
+ };
7002
7409
  return {
7003
7410
  nearest,
7004
- reason: !named.hue ? "hue" : named.distance >= .1 ? "far" : named.foreign ? "scoped" : "role",
7411
+ reason,
7005
7412
  sameValue
7006
7413
  };
7007
7414
  }
@@ -7057,7 +7464,7 @@ var TokenIndex = class {
7057
7464
  function colorModes(token) {
7058
7465
  const modes = {};
7059
7466
  for (const [mode, value] of Object.entries(token.modes ?? {})) {
7060
- const color = parseColor(value, { bareHsl: true });
7467
+ const color = parseColor(value, TOKEN_COLOR);
7061
7468
  if (color) modes[mode] = color;
7062
7469
  }
7063
7470
  return Object.keys(modes).length ? { modes } : {};
@@ -7551,6 +7958,16 @@ var LintTarget = class {
7551
7958
  return this.importPrefixes.some((prefix) => specifier.startsWith(prefix) || specifier === prefix.slice(0, -1));
7552
7959
  }
7553
7960
  /**
7961
+ * Whether the component rules judge the components imported from `specifier`: it points at
7962
+ * the design system, and the model has components to judge them by. A model with none (an
7963
+ * app whose design-system package publishes only compiled `dist/`) still matches the
7964
+ * configured `importPath`, but the component rules do not run there, as `check` says, so
7965
+ * what comes from it is not called invented.
7966
+ */
7967
+ checksImport(specifier, fromFile = "snippet.tsx") {
7968
+ return this.model.components.length > 0 && this.isDesignSystemImport(specifier, fromFile);
7969
+ }
7970
+ /**
7554
7971
  * Whether `name`, imported from the design system, may be a real export the
7555
7972
  * model left out rather than an invented one: its module exists outside the
7556
7973
  * folders extraction read and exports `name`, or re-exports from such a
@@ -7969,7 +8386,10 @@ const noUnknownComponent = {
7969
8386
  }
7970
8387
  }
7971
8388
  };
7972
- /** Prop names from other libraries → the conventional name in shadcn-style systems. */
8389
+ /**
8390
+ * Prop names from other libraries → the conventional name in shadcn-style systems, which mean the
8391
+ * same thing there, so the rename is a fix.
8392
+ */
7973
8393
  const PROP_SYNONYMS = {
7974
8394
  variant: [
7975
8395
  "tone",
@@ -7999,20 +8419,20 @@ const PROP_SYNONYMS = {
7999
8419
  "show",
8000
8420
  "shown"
8001
8421
  ],
8002
- onOpenChange: [
8003
- "onClose",
8004
- "onDismiss",
8005
- "onToggle",
8006
- "onOpen",
8007
- "onVisibleChange"
8008
- ],
8009
- asChild: [
8010
- "as",
8011
- "component",
8012
- "render"
8013
- ]
8422
+ onOpenChange: ["onVisibleChange"]
8014
8423
  };
8015
8424
  /**
8425
+ * Handlers other libraries call on one side of a change, against `onOpenChange`, which runs on
8426
+ * open and on close with the new state: renamed, `onClose={() => setOpen(false)}` would keep a
8427
+ * dialog shut. The message shows the check to add instead of offering the rename.
8428
+ */
8429
+ const OPEN_CHANGE_HANDLERS = /* @__PURE__ */ new Map([
8430
+ ["onClose", "{ if (!open) … }"],
8431
+ ["onDismiss", "{ if (!open) … }"],
8432
+ ["onOpen", "{ if (open) … }"],
8433
+ ["onToggle", "…"]
8434
+ ]);
8435
+ /**
8016
8436
  * Props that mean the same thing in different conventions: native elements and Radix on one
8017
8437
  * side, React Aria Components on the other. A native field states `isInvalid` as `aria-invalid`. Whichever spelling an agent writes, the one the
8018
8438
  * component takes is suggested, in either direction. Event handlers are left out: their
@@ -8036,14 +8456,96 @@ const PROP_EQUIVALENTS = [
8036
8456
  ["defaultChecked", "defaultSelected"],
8037
8457
  ["indeterminate", "isIndeterminate"]
8038
8458
  ];
8459
+ /** Props other libraries compose with, which no prop of a similar spelling stands in for. */
8460
+ const FOREIGN_COMPOSITION = /* @__PURE__ */ new Set([
8461
+ "href",
8462
+ "as",
8463
+ "component"
8464
+ ]);
8039
8465
  /**
8040
8466
  * Radix composes with `asChild` and a child element, Base UI with a `render`
8041
- * element; shadcn/ui ships both, and agents mix them up. Renaming the prop is
8042
- * not enough, so this explains the other way instead of offering a fix.
8467
+ * element; shadcn/ui ships both, and agents mix them up, or bring `as`, `component`
8468
+ * or `href` from other libraries. Renaming the prop is not enough (`asChild` is a
8469
+ * boolean, and a button with an `href` is still no link), so this explains the
8470
+ * other way instead of offering a fix.
8043
8471
  */
8044
8472
  function compositionHint(name, known, tag) {
8045
- if (name === "asChild" && known.has("render")) return `Base UI components compose with render instead: <${tag} render={<Link href="…" />}>…</${tag}>.`;
8046
- if (name === "render" && known.has("asChild")) return `Radix components compose with asChild and a single child instead: <${tag} asChild><Link href="…">…</Link></${tag}>.`;
8473
+ const radix = `compose with asChild and a single child`;
8474
+ const radixExample = `<${tag} asChild><Link href="…">…</Link></${tag}>`;
8475
+ const base = `compose with render`;
8476
+ const baseExample = `<${tag} render={<Link href="…" />}>…</${tag}>`;
8477
+ if (name === "href") {
8478
+ if (known.has("asChild")) return {
8479
+ prop: "asChild",
8480
+ text: `For a link, Radix components ${radix}: ${radixExample}.`
8481
+ };
8482
+ if (known.has("render")) return {
8483
+ prop: "render",
8484
+ text: `For a link, Base UI components ${base}: ${baseExample}.`
8485
+ };
8486
+ return;
8487
+ }
8488
+ if ([
8489
+ "asChild",
8490
+ "as",
8491
+ "component"
8492
+ ].includes(name) && known.has("render")) return {
8493
+ prop: "render",
8494
+ text: `Base UI components ${base} instead: ${baseExample}.`
8495
+ };
8496
+ if ([
8497
+ "render",
8498
+ "as",
8499
+ "component"
8500
+ ].includes(name) && known.has("asChild")) return {
8501
+ prop: "asChild",
8502
+ text: `Radix components ${radix} instead: ${radixExample}.`
8503
+ };
8504
+ }
8505
+ /** `onClick`, `onValueChange`: a handler, whose arguments a spelling match does not tell. */
8506
+ const HANDLER = /^on[A-Z]/;
8507
+ /**
8508
+ * The prop an unknown one most likely means, and whether the rename is safe as a fix: an
8509
+ * equivalent, a synonym or the same name in other case (`onclick`, `tabindex`) means the same,
8510
+ * and so does an own prop one edit away (`varient`, `onPres`). A forwarded attribute of a
8511
+ * similar spelling is only named, since one letter can make another prop (`disable` is
8512
+ * `disabled`, but `href` is not `ref`). `ref`, own or forwarded, and forwarded handlers, whose
8513
+ * events differ (`onValueChange` is not `onVolumeChange`), are not guessed by spelling at all,
8514
+ * and neither is anything for `href`, `as` or `component` (`ref`, `is`), which compose.
8515
+ */
8516
+ function guessProp(name, own, known) {
8517
+ if (FOREIGN_COMPOSITION.has(name)) return void 0;
8518
+ const equivalents = PROP_EQUIVALENTS.find((group) => group.includes(name)) ?? [];
8519
+ const equivalent = equivalents.find((other) => other !== name && own.includes(other)) ?? equivalents.find((other) => other !== name && known.has(other));
8520
+ if (equivalent) return {
8521
+ prop: equivalent,
8522
+ fix: true
8523
+ };
8524
+ const synonym = Object.entries(PROP_SYNONYMS).find(([target, aliases]) => known.has(target) && aliases.includes(name))?.[0];
8525
+ if (synonym) return {
8526
+ prop: synonym,
8527
+ fix: true
8528
+ };
8529
+ if (OPEN_CHANGE_HANDLERS.has(name) && known.has("onOpenChange")) return {
8530
+ prop: "onOpenChange",
8531
+ fix: false
8532
+ };
8533
+ const lower = name.toLowerCase();
8534
+ const recased = [...known].find((prop) => prop.toLowerCase() === lower);
8535
+ if (recased) return {
8536
+ prop: recased,
8537
+ fix: true
8538
+ };
8539
+ const typo = closest(name, own.filter((prop) => prop !== "ref"));
8540
+ if (typo) return {
8541
+ prop: typo,
8542
+ fix: editDistance(lower, typo.toLowerCase()) <= 1
8543
+ };
8544
+ const similar = closest(name, [...known].filter((prop) => prop !== "ref" && !HANDLER.test(prop)), .25);
8545
+ return similar ? {
8546
+ prop: similar,
8547
+ fix: false
8548
+ } : void 0;
8047
8549
  }
8048
8550
  const noUnknownProp = {
8049
8551
  id: "no-unknown-prop",
@@ -8066,27 +8568,25 @@ const noUnknownProp = {
8066
8568
  context.report({
8067
8569
  start: attribute.name.getStart(context.sourceFile),
8068
8570
  end: attribute.name.end,
8069
- message: `<${element.tag}> has no prop "${name}". ${composition}`,
8070
- suggestion: name === "asChild" ? "render" : "asChild"
8571
+ message: `<${element.tag}> has no prop "${name}". ${composition.text}`,
8572
+ suggestion: composition.prop
8071
8573
  });
8072
8574
  continue;
8073
8575
  }
8074
- const equivalents = PROP_EQUIVALENTS.find((group) => group.includes(name)) ?? [];
8075
- const equivalent = equivalents.find((other) => other !== name && own.includes(other)) ?? equivalents.find((other) => other !== name && known.has(other));
8076
- const synonym = Object.entries(PROP_SYNONYMS).find(([target, aliases]) => known.has(target) && aliases.includes(name))?.[0];
8077
- const guess = equivalent ?? synonym ?? closest(name, own) ?? closest(name, known, .25);
8078
- const prop = guess ? component.props.find((p) => p.name === guess) : void 0;
8576
+ const guess = guessProp(name, own, known);
8577
+ const prop = guess ? component.props.find((p) => p.name === guess.prop) : void 0;
8079
8578
  const values = prop?.values?.length ? ` (${prop.values.map((v) => `"${v}"`).join(" | ")})` : "";
8579
+ const handler = guess?.prop === "onOpenChange" ? OPEN_CHANGE_HANDLERS.get(name) : void 0;
8080
8580
  const valueOk = !prop?.values || literalValues(attribute).every((l) => prop.values?.includes(l.text));
8081
- const taken = guess !== void 0 && findAttribute(element, guess) !== void 0;
8581
+ const taken = guess !== void 0 && findAttribute(element, guess.prop) !== void 0;
8082
8582
  context.report({
8083
8583
  start: attribute.name.getStart(context.sourceFile),
8084
8584
  end: attribute.name.end,
8085
- message: `<${element.tag}> has no prop "${name}".` + (guess ? ` Did you mean "${guess}"${values}?` : ""),
8086
- suggestion: guess,
8087
- fix: guess && valueOk && !taken ? [{
8585
+ message: `<${element.tag}> has no prop "${name}".` + (guess ? ` Did you mean "${guess.prop}"${values}?` : "") + (handler ? ` It is called with the new open state, on open as well as on close: onOpenChange={(open) => ${handler}}.` : ""),
8586
+ suggestion: guess?.prop,
8587
+ fix: guess?.fix && valueOk && !taken ? [{
8088
8588
  range: [attribute.name.getStart(context.sourceFile), attribute.name.end],
8089
- text: guess
8589
+ text: guess.prop
8090
8590
  }] : void 0
8091
8591
  });
8092
8592
  }
@@ -8177,11 +8677,16 @@ const VALUE_GROUPS = [
8177
8677
  "href",
8178
8678
  "inline"
8179
8679
  ],
8680
+ [
8681
+ "xs",
8682
+ "xsmall",
8683
+ "x-small",
8684
+ "extra-small"
8685
+ ],
8180
8686
  [
8181
8687
  "sm",
8182
8688
  "small",
8183
8689
  "s",
8184
- "xs",
8185
8690
  "compact",
8186
8691
  "tiny"
8187
8692
  ],
@@ -8200,6 +8705,13 @@ const VALUE_GROUPS = [
8200
8705
  "l",
8201
8706
  "big"
8202
8707
  ],
8708
+ [
8709
+ "xl",
8710
+ "xlarge",
8711
+ "x-large",
8712
+ "extra-large",
8713
+ "huge"
8714
+ ],
8203
8715
  [
8204
8716
  "icon",
8205
8717
  "iconOnly",
@@ -8208,14 +8720,72 @@ const VALUE_GROUPS = [
8208
8720
  "icon-button"
8209
8721
  ]
8210
8722
  ];
8211
- function suggestValue(value, allowed) {
8212
- const lower = value.toLowerCase();
8213
- for (const group of VALUE_GROUPS) {
8214
- if (!group.includes(lower)) continue;
8215
- const match = group.find((member) => member !== lower && allowed.includes(member));
8216
- if (match) return match;
8723
+ /**
8724
+ * A value with the x's of its size step counted, so two spellings of one step compare equal:
8725
+ * `xxl` and `2xl` both give `2xl`, `icon-XXS` and `icon-2xs` both give `icon-2xs`.
8726
+ */
8727
+ function stepSpelling(value) {
8728
+ return value.toLowerCase().replace(/(^|-)(x{2,})([sl])$/, (_, start, xs, end) => `${start}${xs.length}x${end}`);
8729
+ }
8730
+ /** {@link VALUE_GROUPS} spelled as {@link stepSpelling} spells a value, to compare the two. */
8731
+ const SPELLED_GROUPS = VALUE_GROUPS.map((group) => group.map(stepSpelling));
8732
+ /**
8733
+ * An allowed value that means the same: the same step spelled another way (`xxl` → `2xl`,
8734
+ * `XL` → `xl`), or a synonym (`danger` → `destructive`, `small` → `sm`). A group is searched
8735
+ * in its own order, not the scale's: TypeScript orders a union by when the program first met
8736
+ * each literal, so another file can reorder the scale, and `positive` must stay `success` on
8737
+ * a scale that also has `active`.
8738
+ */
8739
+ function synonymValue(value, allowed) {
8740
+ const spelling = stepSpelling(value);
8741
+ const same = allowed.find((member) => member !== value && stepSpelling(member) === spelling);
8742
+ if (same) return same;
8743
+ for (const group of SPELLED_GROUPS) {
8744
+ if (!group.includes(spelling)) continue;
8745
+ for (const member of group) {
8746
+ if (member === spelling) continue;
8747
+ const match = allowed.find((candidate) => stepSpelling(candidate) === member);
8748
+ if (match) return match;
8749
+ }
8217
8750
  }
8218
- return closest(value, allowed, .5);
8751
+ }
8752
+ /**
8753
+ * The step a size one past an end of the scale stands next to, with its x's counted and its
8754
+ * prefix kept: `2xl` → `xl`, `3xl` → `2xl`, `2xs` → `xs`, `xs` → `sm`, `icon-2xl` → `icon-xl`.
8755
+ */
8756
+ function stepInside(spelling) {
8757
+ const match = /^(.*-)?(\d*)x([sl])$/.exec(spelling);
8758
+ if (!match) return void 0;
8759
+ const prefix = match[1] ?? "";
8760
+ const end = match[3];
8761
+ if (!match[2]) return end === "s" ? `${prefix}sm` : void 0;
8762
+ const count = Number(match[2]);
8763
+ if (count < 2) return void 0;
8764
+ return `${prefix}${count === 2 ? "" : count - 1}x${end}`;
8765
+ }
8766
+ /**
8767
+ * The end of the scale a size one step past it stands next to, under whichever name the scale
8768
+ * gives it: `2xl` or `xxl` → `xl`, `3xl` → `2xl` or `xxl`, `xs` → `sm` or `small`, `icon-xxl`
8769
+ * → `icon-xl`. It renders another size than the one written, so it is named in the message
8770
+ * but not fixed.
8771
+ */
8772
+ function nextStep(value, allowed) {
8773
+ const step = stepInside(stepSpelling(value));
8774
+ if (!step) return void 0;
8775
+ return allowed.find((member) => stepSpelling(member) === step) ?? synonymValue(step, allowed);
8776
+ }
8777
+ /** The step of a size scale a value names, alone or at its end: `xs`, `lg`, `2xl`, `icon-sm`. */
8778
+ const SIZE_STEP = /(?:^|-)(\d*x*[sml]|sm|md|lg)$/i;
8779
+ /**
8780
+ * An allowed value spelled close to `value`, for the message only: it may be another value
8781
+ * altogether. Two steps of a size scale are never guessed from each other, since one letter
8782
+ * there is another size (`xl` and `xs`, `icon-xl` and `icon-xs`).
8783
+ */
8784
+ function spelledLike(value, allowed) {
8785
+ const guess = closest(value, allowed, .5);
8786
+ const step = SIZE_STEP.exec(value)?.[1]?.toLowerCase();
8787
+ const guessStep = guess ? SIZE_STEP.exec(guess)?.[1]?.toLowerCase() : void 0;
8788
+ return step && guessStep && step !== guessStep ? void 0 : guess;
8219
8789
  }
8220
8790
  const noUnknownVariant = {
8221
8791
  id: "no-unknown-variant",
@@ -8230,7 +8800,8 @@ const noUnknownVariant = {
8230
8800
  if (!prop?.values) continue;
8231
8801
  for (const literal of literalValues(attribute)) {
8232
8802
  if (prop.values.includes(literal.text)) continue;
8233
- const guess = suggestValue(literal.text, prop.values);
8803
+ const synonym = synonymValue(literal.text, prop.values);
8804
+ const guess = synonym ?? nextStep(literal.text, prop.values) ?? spelledLike(literal.text, prop.values);
8234
8805
  const start = literal.getStart(context.sourceFile) + 1;
8235
8806
  const end = literal.end - 1;
8236
8807
  context.report({
@@ -8238,9 +8809,9 @@ const noUnknownVariant = {
8238
8809
  end: end + 1,
8239
8810
  message: `"${literal.text}" is not a valid ${name} for <${element.tag}>. Allowed: ${prop.values.join(", ")}.` + (guess ? ` Did you mean "${guess}"?` : ""),
8240
8811
  suggestion: guess ? `${name}="${guess}"` : void 0,
8241
- fix: guess ? [{
8812
+ fix: synonym ? [{
8242
8813
  range: [start, end],
8243
- text: guess
8814
+ text: synonym
8244
8815
  }] : void 0
8245
8816
  });
8246
8817
  }
@@ -8248,27 +8819,205 @@ const noUnknownVariant = {
8248
8819
  }
8249
8820
  }
8250
8821
  };
8822
+ /**
8823
+ * Labels guessed from an icon's name, for the message. The third pattern names the icons that mean
8824
+ * one thing on a button (a trash can deletes, a magnifier searches), matched against the name
8825
+ * without its digits or `Icon` suffix (`Trash2Icon` is `Trash`): only their label is fixed. A plus
8826
+ * may add or expand, a chevron page or expand, an X close, remove or clear, so those are fixed only
8827
+ * where the code says which (`iconLabel`), and the rest are named without a fix.
8828
+ */
8251
8829
  const ICON_LABELS = [
8252
- [/^(Trash|Delete|Bin)/, "Delete"],
8830
+ [
8831
+ /^(Trash|Delete|Bin)/,
8832
+ "Delete",
8833
+ /^(?:Trash|Delete|Bin)$/
8834
+ ],
8253
8835
  [/^(X|Close|Cross)(Icon)?$|^X(Circle|Square)/, "Close"],
8254
8836
  [/^(Plus|Add)/, "Add"],
8255
- [/^(Pencil|Edit|PenLine|SquarePen)/, "Edit"],
8256
- [/^Search/, "Search"],
8837
+ [
8838
+ /^(Pencil|Edit|PenLine|SquarePen)/,
8839
+ "Edit",
8840
+ /^(?:Pencil|Edit|SquarePen)$/
8841
+ ],
8842
+ [
8843
+ /^Search/,
8844
+ "Search",
8845
+ /^Search$/
8846
+ ],
8257
8847
  [/^(Menu|Hamburger)/, "Open menu"],
8258
- [/^(Settings|Cog|Gear)/, "Settings"],
8259
- [/^(Copy|Clipboard)/, "Copy"],
8260
- [/^Download/, "Download"],
8261
- [/^Upload/, "Upload"],
8848
+ [
8849
+ /^(Settings|Cog|Gear)/,
8850
+ "Settings",
8851
+ /^(?:Settings|Cog|Gear)$/
8852
+ ],
8853
+ [
8854
+ /^(Copy|Clipboard)/,
8855
+ "Copy",
8856
+ /^(?:Copy|ClipboardCopy)$/
8857
+ ],
8858
+ [
8859
+ /^Download/,
8860
+ "Download",
8861
+ /^Download$/
8862
+ ],
8863
+ [
8864
+ /^Upload/,
8865
+ "Upload",
8866
+ /^Upload$/
8867
+ ],
8262
8868
  [/^(MoreHorizontal|MoreVertical|Ellipsis|DotsHorizontal|DotsVertical)/, "More options"],
8263
8869
  [/^ChevronLeft|^ArrowLeft/, "Previous"],
8264
8870
  [/^ChevronRight|^ArrowRight/, "Next"],
8265
8871
  [/^(UserMinus|UserX)/, "Remove member"],
8266
8872
  [/^(UserPlus)/, "Invite member"],
8267
8873
  [/^(RefreshCw|RefreshCcw|RotateCw)/, "Refresh"],
8268
- [/^(Share)/, "Share"],
8269
- [/^(Filter)/, "Filter"],
8874
+ [
8875
+ /^(Share)/,
8876
+ "Share",
8877
+ /^Share$/
8878
+ ],
8879
+ [
8880
+ /^(Filter)/,
8881
+ "Filter",
8882
+ /^Filter$/
8883
+ ],
8270
8884
  [/^(Info)/, "More information"]
8271
8885
  ];
8886
+ /** A part that expands and collapses: `CollapsibleTrigger`, `Accordion.Trigger`, `DisclosureButton`. */
8887
+ const TOGGLE_PART = /(?:Collapsible|Accordion|Disclosure)\w*\.?\w*(?:Trigger|Button)$/;
8888
+ /** A part that closes its dialog, sheet or popover: `DialogClose`, `Dialog.Close`. */
8889
+ const CLOSE_PART = /Close$/;
8890
+ /** A part that opens a menu: `DropdownMenuTrigger`, `Menu.Trigger`, Headless UI's `MenuButton`. */
8891
+ const MENU_TRIGGER = /Menu\w*\.?\w*Trigger$|^(?:\w+\.)?Menu\.?Button$/;
8892
+ /** Classes styled by an expanded state: `data-[state=open]:rotate-90`, `aria-expanded:bg-muted`. */
8893
+ const EXPANDED_CLASS = /data-\[state=(?:open|closed)\]|aria-expanded:/;
8894
+ /** The tags of the button and of every element around it, its `render` host included. */
8895
+ function enclosingTags(context, element) {
8896
+ const tags = [element.tag];
8897
+ ts.findAncestor(element.node.parent, (node) => {
8898
+ if (ts.isJsxElement(node)) tags.push(node.openingElement.tagName.getText(context.sourceFile));
8899
+ return false;
8900
+ });
8901
+ return tags;
8902
+ }
8903
+ /** The names a callee is reached by: `deleteMutation.mutate` is `deleteMutation` and `mutate`. */
8904
+ function calleeNames(expression) {
8905
+ const names = [];
8906
+ let current = expression;
8907
+ for (;;) if (ts.isNonNullExpression(current) || ts.isParenthesizedExpression(current)) current = current.expression;
8908
+ else if (ts.isPropertyAccessExpression(current)) {
8909
+ names.unshift(current.name.text);
8910
+ current = current.expression;
8911
+ } else {
8912
+ if (ts.isIdentifier(current)) names.unshift(current.text);
8913
+ return names;
8914
+ }
8915
+ }
8916
+ /** The words that name each action in a function's name. */
8917
+ const ACTION_WORDS = [
8918
+ ["Remove", ["remove", "delete"]],
8919
+ ["Clear", ["clear", "reset"]],
8920
+ ["Close", [
8921
+ "close",
8922
+ "dismiss",
8923
+ "hide"
8924
+ ]]
8925
+ ];
8926
+ /** Calls that tidy up around the action rather than name it: `clearTimeout(timer)`. */
8927
+ const HOUSEKEEPING = /^(?:clearTimeout|clearInterval|clearImmediate|removeEventListener|removeListener)$/;
8928
+ /**
8929
+ * What one call or handler reference does, by its name: `removeAssertion`, `onClose`,
8930
+ * `setOpen(false)`. A name with words of two actions (`closeDeleteDialog`, `hideRemoveConfirm`) or
8931
+ * with `cancel` (`onCancelDelete`) says none.
8932
+ */
8933
+ function actionOf(names, argument) {
8934
+ const last = names.at(-1) ?? "";
8935
+ if (HOUSEKEEPING.test(last)) return void 0;
8936
+ const words = names.flatMap((name) => name.split(/(?=[A-Z])|_/)).map((w) => w.toLowerCase());
8937
+ if (argument?.kind === ts.SyntaxKind.FalseKeyword && words.some((w) => w === "open" || w === "show" || w === "visible")) return "Close";
8938
+ if (/^set[A-Z]/.test(last) || words.includes("cancel")) return void 0;
8939
+ const actions = ACTION_WORDS.filter(([, named]) => words.some((w) => named.includes(w)));
8940
+ return actions.length === 1 ? actions[0]?.[0] : void 0;
8941
+ }
8942
+ /**
8943
+ * What a button's `onClick` (or React Aria's `onPress`) does, by the names of the functions it
8944
+ * calls or passes, and whether that is sure: only when every name says the same action. A close
8945
+ * button that also tidies up (`form.reset(); setOpen(false)`) or a button that removes a row and
8946
+ * closes a popover is named "Close", or "Remove" when it removes and clears, without a fix.
8947
+ * Callbacks inside the handler run later, on their own events (`mutate(id, { onSuccess: () =>
8948
+ * toast.dismiss() })`), so they do not count.
8949
+ */
8950
+ function handlerAction(attributes) {
8951
+ const found = /* @__PURE__ */ new Set();
8952
+ for (const attribute of attributes) {
8953
+ if (!/^on(?:Click|Press)$/.test(attributeName(attribute))) continue;
8954
+ const init = attribute.initializer;
8955
+ const value = init && ts.isJsxExpression(init) ? init.expression : void 0;
8956
+ if (!value) continue;
8957
+ const reference = actionOf(calleeNames(value));
8958
+ if (reference) found.add(reference);
8959
+ const stack = [value];
8960
+ for (let node = stack.pop(); node; node = stack.pop()) {
8961
+ if (ts.isCallExpression(node)) {
8962
+ const action = actionOf(calleeNames(node.expression), node.arguments[0]);
8963
+ if (action) found.add(action);
8964
+ }
8965
+ ts.forEachChild(node, (child) => {
8966
+ if (!ts.isFunctionLike(child)) stack.push(child);
8967
+ });
8968
+ }
8969
+ }
8970
+ const [only] = found;
8971
+ if (!only) return void 0;
8972
+ if (found.size === 1) return {
8973
+ action: only,
8974
+ sure: true
8975
+ };
8976
+ return {
8977
+ action: found.has("Close") ? "Close" : "Remove",
8978
+ sure: false
8979
+ };
8980
+ }
8981
+ /**
8982
+ * The label for an icon-only button, and whether the fix may write it: only when the icon means
8983
+ * one thing on a button, or the code says what it does. A chevron that expands or collapses gets
8984
+ * no direction; an X is named by its handler (`Remove`, `Clear`, `Close`), and `Close` is fixed
8985
+ * in a close part; "More options" is fixed on a menu's trigger.
8986
+ */
8987
+ function iconLabel(context, element, host, icon) {
8988
+ const entry = icon ? ICON_LABELS.find(([pattern]) => pattern.test(icon)) : void 0;
8989
+ if (!icon || !entry) return { fixed: false };
8990
+ const [, label, plain] = entry;
8991
+ const tags = enclosingTags(context, element);
8992
+ const owners = host ? [element, host] : [element];
8993
+ const attributes = owners.flatMap((owner) => owner.attributes);
8994
+ if (label === "Next" || label === "Previous") return {
8995
+ label: tags.some((tag) => TOGGLE_PART.test(tag)) || attributes.some((a) => /^(?:aria-expanded|data-state)$/.test(attributeName(a))) || context.analysis.classStrings.some((c) => c.element && owners.includes(c.element) && EXPANDED_CLASS.test(c.text)) ? "Toggle" : label,
8996
+ fixed: false
8997
+ };
8998
+ if (label === "Close") {
8999
+ if (tags.some((tag) => CLOSE_PART.test(tag))) return {
9000
+ label,
9001
+ fixed: true
9002
+ };
9003
+ const handler = handlerAction(attributes);
9004
+ return handler ? {
9005
+ label: handler.action,
9006
+ fixed: handler.sure
9007
+ } : {
9008
+ label,
9009
+ fixed: false
9010
+ };
9011
+ }
9012
+ if (label === "More options") return {
9013
+ label,
9014
+ fixed: tags.some((tag) => MENU_TRIGGER.test(tag))
9015
+ };
9016
+ return {
9017
+ label,
9018
+ fixed: plain?.test(icon.replace(/Icon$/, "").replace(/\d+$/, "")) ?? false
9019
+ };
9020
+ }
8272
9021
  /** Text wins, then content the rule cannot read, then icons. */
8273
9022
  const PRECEDENCE = [
8274
9023
  "text",
@@ -8474,14 +9223,13 @@ const iconButtonAccessibleName = {
8474
9223
  const content = host && !children.length ? "unknown" : classify(children, context, icons);
8475
9224
  const sizeIcon = attributeLiterals(element, "size").some((v) => v.text.includes("icon"));
8476
9225
  if (content === "text" || content === "unknown" || content === "empty" && !sizeIcon && resolution.kind !== "intrinsic") continue;
8477
- const icon = icons[0];
8478
- const label = icon ? ICON_LABELS.find(([pattern]) => pattern.test(icon))?.[1] : void 0;
9226
+ const { label, fixed } = iconLabel(context, element, host, icons[0]);
8479
9227
  const attribute = `aria-label="${label ?? "…"}"`;
8480
9228
  context.report({
8481
9229
  ...tagRange(context, element),
8482
9230
  message: content === "icon" ? `Icon-only <${element.tag}> has no accessible name. Add ${attribute} describing the action, or visually hidden text.` : `<${element.tag}> has no content and no accessible name. Add ${attribute}.`,
8483
9231
  suggestion: attribute,
8484
- fix: label ? [{
9232
+ fix: fixed ? [{
8485
9233
  range: [element.tagName.end, element.tagName.end],
8486
9234
  text: ` ${attribute}`
8487
9235
  }] : void 0
@@ -8491,6 +9239,58 @@ const iconButtonAccessibleName = {
8491
9239
  };
8492
9240
  //#endregion
8493
9241
  //#region src/lint/rules/hardcoded.ts
9242
+ /** Luminance values, not design colors: `white`/`black` (and hex twins). */
9243
+ const MASK_LUMINANCE = /* @__PURE__ */ new Set([
9244
+ "white",
9245
+ "black",
9246
+ "#fff",
9247
+ "#ffffff",
9248
+ "#000",
9249
+ "#000000"
9250
+ ]);
9251
+ /**
9252
+ * Whether the element sits inside an SVG `<mask>`: a stop's luminance sets
9253
+ * opacity there, so `white`/`black` stops are not design-system colors (#59).
9254
+ */
9255
+ function isInsideMask(element) {
9256
+ const sourceFile = element.node.getSourceFile();
9257
+ return ts.findAncestor(element.node, (node) => {
9258
+ if (!ts.isJsxElement(node) && !ts.isJsxSelfClosingElement(node)) return false;
9259
+ return (ts.isJsxElement(node) ? node.openingElement : node).tagName.getText(sourceFile) === "mask";
9260
+ }) !== void 0;
9261
+ }
9262
+ /** `id` of the wrapping gradient, if the stop sits directly in one. */
9263
+ function gradientIdOfStop(element) {
9264
+ const gradient = ts.findAncestor(element.node.parent, (node) => ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node));
9265
+ if (gradient === void 0) return void 0;
9266
+ const opening = ts.isJsxElement(gradient) ? gradient.openingElement : gradient;
9267
+ const tag = opening.tagName.getText(element.node.getSourceFile());
9268
+ if (tag !== "linearGradient" && tag !== "radialGradient") return;
9269
+ const idAttr = opening.attributes.properties.filter(ts.isJsxAttribute).find((a) => attributeName(a) === "id");
9270
+ return idAttr?.initializer && ts.isStringLiteralLike(idAttr.initializer) ? idAttr.initializer.text : void 0;
9271
+ }
9272
+ const URL_REF = /url\(\s*#([^)\s]+)\s*\)/g;
9273
+ /**
9274
+ * Gradient ids referenced via `fill="url(#id)"` (or `stroke`) from inside a
9275
+ * `<mask>` subtree: stops of those gradients paint mask luminance, not
9276
+ * colors (#59, cal.com gridFade/gridMask shape).
9277
+ */
9278
+ function maskReferencedGradientIds(elements) {
9279
+ const ids = /* @__PURE__ */ new Set();
9280
+ const refsFrom = (node) => {
9281
+ if (ts.isJsxAttribute(node)) {
9282
+ const name = attributeName(node);
9283
+ if ((name === "fill" || name === "stroke") && node.initializer && ts.isStringLiteralLike(node.initializer)) {
9284
+ URL_REF.lastIndex = 0;
9285
+ let match;
9286
+ while ((match = URL_REF.exec(node.initializer.text)) !== null) if (match[1]) ids.add(match[1]);
9287
+ }
9288
+ }
9289
+ ts.forEachChild(node, refsFrom);
9290
+ };
9291
+ for (const element of elements) if (element.tag === "mask") refsFrom(element.node);
9292
+ return ids;
9293
+ }
8494
9294
  const noHardcodedColor = {
8495
9295
  id: "no-hardcoded-color",
8496
9296
  description: "Colors must come from design tokens: no hex/rgb/oklch literals, arbitrary Tailwind colors or default-palette classes.",
@@ -8498,71 +9298,94 @@ const noHardcodedColor = {
8498
9298
  const { tokens } = context.target;
8499
9299
  if (!tokens.has("color")) return;
8500
9300
  const allow = new Set((context.options.allow ?? []).map((v) => v.toLowerCase()));
8501
- for (const classString of context.analysis.classStrings) for (const token of splitClasses(classString.text, classString.start)) {
8502
- if (allow.has(token.value.toLowerCase())) continue;
8503
- const utility = parseUtility(token.value);
8504
- const element = classString.element;
8505
- if (utility.arbitrary !== void 0 && utility.prefix && COLOR_PREFIXES.includes(utility.prefix)) {
8506
- const value = utility.arbitrary.replace(/^color:/, "").trim();
8507
- const whole = parseColor(value);
8508
- const literal = whole ? {
8509
- text: value,
8510
- color: whole
8511
- } : findColorLiterals(value)[0];
8512
- if (!literal || allow.has(literal.text.toLowerCase())) continue;
8513
- reportColor(context, {
8514
- start: token.start,
8515
- end: token.end,
8516
- what: `Hardcoded color \`${token.value}\``,
8517
- color: literal.color,
8518
- prefix: whole ? utility.prefix : void 0,
8519
- place: "class",
8520
- utility,
8521
- element,
8522
- classes: classString.text
8523
- });
8524
- continue;
8525
- }
8526
- const property = utility.property ? cssColorProperty(utility.property) : void 0;
8527
- if (property && utility.arbitrary !== void 0) {
8528
- const value = utility.arbitrary.trim();
8529
- const whole = parseColor(value);
8530
- const literal = whole ? {
8531
- text: value,
8532
- color: whole
8533
- } : findColorLiterals(value)[0];
8534
- if (!literal || allow.has(literal.text.toLowerCase())) continue;
8535
- reportColor(context, {
8536
- start: token.start,
8537
- end: token.end,
8538
- what: `Hardcoded color \`${token.value}\``,
8539
- color: literal.color,
8540
- prefix: whole ? property.prefix : void 0,
8541
- place: "class",
8542
- utility,
8543
- element,
8544
- classes: classString.text
8545
- });
8546
- continue;
8547
- }
8548
- const palette = paletteColor(utility.base);
8549
- if (palette && !tokens.colorKeys.has(palette.key)) {
8550
- const color = parseColor(palette.value);
8551
- if (!color) continue;
8552
- reportColor(context, {
8553
- start: token.start,
8554
- end: token.end,
8555
- what: `\`${token.value}\` is Tailwind's default palette, not a design-system color`,
8556
- color,
8557
- tinted: !palette.gray,
8558
- prefix: palette.prefix,
8559
- place: "class",
8560
- utility,
8561
- element,
8562
- classes: classString.text
8563
- });
9301
+ const maskGradients = maskReferencedGradientIds(context.analysis.elements);
9302
+ const lists = /* @__PURE__ */ new Map();
9303
+ for (const classString of context.analysis.classStrings) {
9304
+ const key = classString.element ?? classString;
9305
+ const list = lists.get(key) ?? [];
9306
+ lists.set(key, list);
9307
+ for (const token of splitClasses(classString.text, classString.start)) {
9308
+ const utility = parseUtility(token.value);
9309
+ const listed = {
9310
+ value: token.value,
9311
+ prefix: tokenColorPrefix(utility, tokens.colorKeys)
9312
+ };
9313
+ list.push(listed);
9314
+ if (allow.has(token.value.toLowerCase())) continue;
9315
+ const element = classString.element;
9316
+ if (utility.arbitrary !== void 0 && utility.prefix && COLOR_PREFIXES.includes(utility.prefix)) {
9317
+ const value = utility.arbitrary.replace(/^color:/, "").trim();
9318
+ const whole = parseColor(value);
9319
+ const literal = whole ? {
9320
+ text: value,
9321
+ color: whole
9322
+ } : findColorLiterals(value)[0];
9323
+ if (!literal || allow.has(literal.text.toLowerCase())) continue;
9324
+ listed.color = literal.color;
9325
+ listed.prefix = whole ? utility.prefix : void 0;
9326
+ listed.report = colorReport(context, {
9327
+ start: token.start,
9328
+ end: token.end,
9329
+ what: `Hardcoded color \`${token.value}\``,
9330
+ color: literal.color,
9331
+ prefix: listed.prefix,
9332
+ place: "class",
9333
+ utility,
9334
+ element,
9335
+ classes: classString.text
9336
+ });
9337
+ continue;
9338
+ }
9339
+ const property = utility.property ? cssColorProperty(utility.property) : void 0;
9340
+ if (property && utility.arbitrary !== void 0) {
9341
+ const value = utility.arbitrary.trim();
9342
+ const whole = parseColor(value);
9343
+ const literal = whole ? {
9344
+ text: value,
9345
+ color: whole
9346
+ } : findColorLiterals(value)[0];
9347
+ if (!literal || allow.has(literal.text.toLowerCase())) continue;
9348
+ listed.color = literal.color;
9349
+ listed.prefix = whole ? property.prefix : void 0;
9350
+ listed.report = colorReport(context, {
9351
+ start: token.start,
9352
+ end: token.end,
9353
+ what: `Hardcoded color \`${token.value}\``,
9354
+ color: literal.color,
9355
+ prefix: listed.prefix,
9356
+ place: "class",
9357
+ utility,
9358
+ element,
9359
+ classes: classString.text
9360
+ });
9361
+ continue;
9362
+ }
9363
+ const palette = paletteColor(utility.base);
9364
+ if (palette && !tokens.colorKeys.has(palette.key)) {
9365
+ const color = parseColor(palette.value);
9366
+ if (!color) continue;
9367
+ listed.color = color;
9368
+ listed.prefix = palette.prefix;
9369
+ listed.report = colorReport(context, {
9370
+ start: token.start,
9371
+ end: token.end,
9372
+ what: `\`${token.value}\` is Tailwind's default palette, not a design-system color`,
9373
+ color,
9374
+ tinted: !palette.gray,
9375
+ palette: palette.key,
9376
+ prefix: palette.prefix,
9377
+ place: "class",
9378
+ utility,
9379
+ element,
9380
+ classes: classString.text
9381
+ });
9382
+ }
8564
9383
  }
8565
9384
  }
9385
+ for (const list of lists.values()) {
9386
+ keepStatesApart(list);
9387
+ for (const { report } of list) if (report) context.report(report);
9388
+ }
8566
9389
  for (const { object, element } of context.analysis.styles) for (const prop of object.properties) {
8567
9390
  if (!ts.isPropertyAssignment(prop)) continue;
8568
9391
  const name = propertyName(prop.name);
@@ -8585,13 +9408,16 @@ const noHardcodedColor = {
8585
9408
  if (!isColorAttribute(name)) continue;
8586
9409
  if (resolution.kind === "component" && resolution.component.props.some((p) => p.name === name && p.values?.length)) continue;
8587
9410
  const prefix = name === "fill" || name === "stroke" ? name : "text";
8588
- for (const literal of literalValues(attribute)) reportLiteralColors(context, literal, element, {
8589
- where: `${name}="…"`,
8590
- prefix,
8591
- place: "attribute",
8592
- named: resolution.kind === "intrinsic",
8593
- allow
8594
- });
9411
+ for (const literal of literalValues(attribute)) {
9412
+ if ((name === "stopColor" || name === "stop-color") && element.tag === "stop" && MASK_LUMINANCE.has(literal.text.toLowerCase()) && (isInsideMask(element) || maskGradients.has(gradientIdOfStop(element) ?? ""))) continue;
9413
+ reportLiteralColors(context, literal, element, {
9414
+ where: `${name}="…"`,
9415
+ prefix,
9416
+ place: "attribute",
9417
+ named: resolution.kind === "intrinsic",
9418
+ allow
9419
+ });
9420
+ }
8595
9421
  }
8596
9422
  }
8597
9423
  }
@@ -8611,7 +9437,7 @@ function reportLiteralColors(context, literal, element, options) {
8611
9437
  }
8612
9438
  for (const match of matches) {
8613
9439
  if (options.allow.has(match.text.toLowerCase())) continue;
8614
- reportColor(context, {
9440
+ const report = colorReport(context, {
8615
9441
  start: start + match.index,
8616
9442
  end: start + match.index + match.text.length,
8617
9443
  what: `Hardcoded color \`${match.text}\` in ${options.where}`,
@@ -8620,9 +9446,11 @@ function reportLiteralColors(context, literal, element, options) {
8620
9446
  place: options.place,
8621
9447
  element
8622
9448
  });
9449
+ if (report) context.report(report);
8623
9450
  }
8624
9451
  }
8625
- function reportColor(context, input) {
9452
+ /** The finding for a color, or undefined when the design system has no color to name. */
9453
+ function colorReport(context, input) {
8626
9454
  const role = colorRole(input.prefix ?? input.utility?.prefix);
8627
9455
  const mode = input.utility?.variants.includes("dark") ? "dark" : void 0;
8628
9456
  const suggestion = context.target.tokens.suggestColor(input.color, {
@@ -8631,13 +9459,53 @@ function reportColor(context, input) {
8631
9459
  tinted: input.tinted,
8632
9460
  mode
8633
9461
  });
8634
- if (!suggestion) return;
8635
- const { match, nearest, reason, sameValue, hueOff } = suggestion;
9462
+ if (!suggestion) return void 0;
9463
+ const { match, nearest, reason, sameValue, hueOff, otherRole, closeStatus } = suggestion;
8636
9464
  const token = nearest.candidate.token;
8637
- const cls = input.prefix && token.tailwind ? `${input.prefix}-${token.tailwind}` : void 0;
8638
- const cssVar = cssReference(token);
9465
+ const { cls, replacement, advice, edit } = writing(input, token);
8639
9466
  const close = match !== void 0;
9467
+ const held = close && input.palette && nearest.distance >= .02 ? heldBack(input.palette, valueIn(nearest.candidate, mode), nearest.distance) : void 0;
9468
+ const status = close && closeStatus ? statusTooClose(input, mode, closeStatus, token, advice) : void 0;
8640
9469
  const variant = cls && input.element ? variantApplying(context, input.element, cls) : void 0;
9470
+ const same = sameValue.length ? `, same value as ${sameValue.map((t) => t.name).join(", ")}` : "";
9471
+ const inMode = mode && token.modes?.[mode] !== void 0 ? ` in ${mode} mode` : "";
9472
+ const value = valueIn(nearest.candidate, mode);
9473
+ const found = `${token.name}${inMode} (${formatDistance(input.color, value)}${same})`;
9474
+ const opacityGap = formatOpacityGap(input.color, value, `${token.name}${inMode}`);
9475
+ const tinted = input.tinted ?? isTinted(input.color);
9476
+ const noHue = !close && reason === "hue" && tinted;
9477
+ let message;
9478
+ if (close) {
9479
+ message = `${input.what}. ${nearest.distance < .02 ? "Matches token" : "Nearest token"} ${found}`;
9480
+ if (advice) message += ` → ${advice}`;
9481
+ if (variant) message += `, or use ${variant}`;
9482
+ message += ".";
9483
+ if (opacityGap) message += ` ${opacityGap}`;
9484
+ if (status) message += ` ${status}`;
9485
+ else if (held) message += ` ${held}`;
9486
+ } else if (noHue) message = `${input.what}. ${NO_TOKEN_HAS_HUE}`;
9487
+ else message = `${input.what}. ${noMatch(reason, role, found, hueOff)}${meantForAnother(otherRole, input.color, mode)}${opacityGap ? ` ${opacityGap}` : ""} Pick the semantic token that fits.`;
9488
+ const owner = !variant && input.prefix && input.element ? variantProp(context, input.element, input.prefix) : void 0;
9489
+ if (owner) message += ` <${owner.component}> already sets ${input.prefix}-* through \`${owner.prop}\`; prefer a variant over overriding it.`;
9490
+ return {
9491
+ start: input.start,
9492
+ end: input.end,
9493
+ message,
9494
+ suggestion: close ? variant ?? replacement : void 0,
9495
+ fix: close && edit && !held && !status ? [{
9496
+ range: [input.start, input.end],
9497
+ text: edit
9498
+ }] : void 0,
9499
+ severity: noHue ? "warning" : void 0
9500
+ };
9501
+ }
9502
+ /**
9503
+ * How code writes `token` where the color is: the class (`bg-primary`), what to suggest, the
9504
+ * message's advice and the text a fix puts in place of the color.
9505
+ */
9506
+ function writing(input, token) {
9507
+ const cls = input.prefix && token.tailwind ? `${input.prefix}-${token.tailwind}` : void 0;
9508
+ const cssVar = cssReference(token);
8641
9509
  let replacement;
8642
9510
  let advice;
8643
9511
  let edit;
@@ -8654,45 +9522,237 @@ function reportColor(context, input) {
8654
9522
  advice = cls ? `\`${cls}\` in className` : void 0;
8655
9523
  }
8656
9524
  if (!cls && !cssVar && token.usage.length) advice = token.usage.map((usage) => `\`${usage}\``).join(" or ");
8657
- const same = sameValue.length ? `, same value as ${sameValue.map((t) => t.name).join(", ")}` : "";
9525
+ return {
9526
+ cls,
9527
+ replacement,
9528
+ advice,
9529
+ edit
9530
+ };
9531
+ }
9532
+ /**
9533
+ * Why a close match is not applied when a status color of the color's hue is about as close:
9534
+ * `destructive is close too (ΔE 0.071), and it is a status color, so this is not applied
9535
+ * automatically: use \`bg-destructive\` if the color shows a status, otherwise \`bg-brand\`.`
9536
+ */
9537
+ function statusTooClose(input, mode, status, found, advice) {
9538
+ const { token } = status.candidate;
8658
9539
  const inMode = mode && token.modes?.[mode] !== void 0 ? ` in ${mode} mode` : "";
8659
- const found = `${token.name}${inMode} (${formatDeltaE(nearest.distance)}${same})`;
8660
- let message;
8661
- if (close) {
8662
- message = `${input.what}. ${nearest.distance < .02 ? "Matches token" : "Nearest token"} ${found}`;
8663
- if (advice) message += ` → ${advice}`;
8664
- if (variant) message += `, or use ${variant}`;
8665
- message += ".";
8666
- } else {
8667
- const tinted = input.tinted ?? isTinted(input.color);
8668
- message = `${input.what}. ${noMatch(reason, role, tinted, nearest.candidate.color, found, hueOff)} Pick the semantic token that fits.`;
9540
+ const distance = formatDistance(input.color, valueIn(status.candidate, mode));
9541
+ const statusAdvice = writing(input, token).advice || token.name;
9542
+ return `${token.name}${inMode} is close too (${distance}), and it is a status color, so this is not applied automatically: use ${statusAdvice} if the color shows a status, otherwise ${advice || found.name}.`;
9543
+ }
9544
+ /** Color keywords that are no token but set a color (`bg-white`, `border-transparent`). */
9545
+ const KEYWORD_COLORS = /* @__PURE__ */ new Set([
9546
+ "white",
9547
+ "black",
9548
+ "transparent",
9549
+ "current",
9550
+ "inherit"
9551
+ ]);
9552
+ /** The prefix of a class that sets a token's color or a keyword color: `bg` for `bg-primary`. */
9553
+ function tokenColorPrefix(utility, colorKeys) {
9554
+ const key = COLOR_UTILITY.exec(utility.base)?.[1];
9555
+ if (!key || !(colorKeys.has(key) || KEYWORD_COLORS.has(key))) return void 0;
9556
+ return utility.base.slice(0, -key.length - 1);
9557
+ }
9558
+ /**
9559
+ * Takes back a fix that would make a class under a condition (`hover:`, `focus-visible:`, `md:`)
9560
+ * the same color as the class without it: `bg-[#2563eb] hover:bg-[#1d4ed8]` would become
9561
+ * `bg-primary hover:bg-primary`, and the hover would change nothing. The state class gives way
9562
+ * first; a base class does when the state class already has the token it would get. `dark:` is
9563
+ * no such condition: dark classes are compared with the tokens' dark values, where the same
9564
+ * token is right. Two hardcoded classes of exactly the same color keep their fixes: the
9565
+ * condition changed nothing before either (`border-red-500 focus:border-red-500` keeps an error
9566
+ * border red on focus), and a hardcoded color is the same in every mode. A token class is not:
9567
+ * the class list's `bg-primary` is another color in dark mode than a `hover:bg-[#171717]` next
9568
+ * to it. A `dark:` state class starts from the list's `dark:` class, when it has one.
9569
+ */
9570
+ function keepStatesApart(list) {
9571
+ const after = (listed) => listed.report?.fix?.[0]?.text ?? listed.value;
9572
+ for (const pass of ["state", "base"]) for (const listed of list) {
9573
+ const { report } = listed;
9574
+ if (!report?.fix?.[0]) continue;
9575
+ for (const other of list) {
9576
+ if (other === listed || oneColor(listed.color, other.color)) continue;
9577
+ const [stateListed, baseListed] = pass === "state" ? [listed, other] : [other, listed];
9578
+ const state = parseUtility(after(stateListed));
9579
+ const base = parseUtility(after(baseListed));
9580
+ const conditions = conditionsBetween(state, base);
9581
+ if (!conditions || hasDarkBase(list, stateListed, baseListed, state, base, after)) continue;
9582
+ const had = pass === "base" ? `\`${other.value}\` is in the class list too` : after(other) === other.value ? `The class list already has \`${other.value}\`` : `\`${other.value}\` becomes \`${after(other)}\` too`;
9583
+ const apart = state.modifier ? "such as with another opacity modifier" : `such as with \`${withBase({
9584
+ ...state,
9585
+ modifier: "/90"
9586
+ }, state.base)}\``;
9587
+ report.message += ` ${had}, so with this fix \`${conditions}\` would change nothing: it is not applied automatically. Keep the two apart, ${apart}.`;
9588
+ report.fix = void 0;
9589
+ break;
9590
+ }
8669
9591
  }
8670
- const owner = !variant && input.prefix && input.element ? variantProp(context, input.element, input.prefix) : void 0;
8671
- if (owner) message += ` <${owner.component}> already sets ${input.prefix}-* through \`${owner.prop}\`; prefer a variant over overriding it.`;
8672
- context.report({
8673
- start: input.start,
8674
- end: input.end,
8675
- message,
8676
- suggestion: close ? variant ?? replacement : void 0,
8677
- fix: close && edit ? [{
8678
- range: [input.start, input.end],
8679
- text: edit
8680
- }] : void 0
9592
+ }
9593
+ /**
9594
+ * Below this ΔE two hardcoded colors are one color written two ways: a palette class and its
9595
+ * hex (`neutral-100` is 0.00015 from `#f5f5f5`), or an OKLCH value rounded to hex (red-500 is
9596
+ * 0.00097 from `#fb2c36`). SAME_COLOR is too loose for this: `#fafafa` is 0.015 from `#fff`,
9597
+ * and a hover from one to the other is a change the fix must not remove.
9598
+ */
9599
+ const ONE_COLOR = .003;
9600
+ /** Whether two hardcoded colors are exactly one color, so one token for both changes nothing. */
9601
+ function oneColor(a, b) {
9602
+ return a !== void 0 && b !== void 0 && colorDistance(a, b) < ONE_COLOR;
9603
+ }
9604
+ /**
9605
+ * Whether the class list has the base class's dark-mode counterpart, which a `dark:` state class
9606
+ * starts from instead: with `bg-[#171717] dark:bg-[#0a0a0a]`, `dark:hover:` changes the dark
9607
+ * background, not `bg-[#171717]`. Only a class that adds `dark:` and nothing else to the base
9608
+ * class's variants counts: one that adds a condition (`group-hover:`) is off with it.
9609
+ */
9610
+ function hasDarkBase(list, stateListed, baseListed, state, base, after) {
9611
+ if (baseListed.prefix === void 0) return false;
9612
+ if (base.variants.includes("dark") || !state.variants.includes("dark")) return false;
9613
+ return list.some((other) => {
9614
+ if (other === stateListed || other === baseListed || other.prefix !== baseListed.prefix) return false;
9615
+ const { variants } = parseUtility(after(other));
9616
+ return variants.includes("dark") && variants.every((variant) => variant === "dark" || base.variants.includes(variant)) && base.variants.every((variant) => variants.includes(variant));
8681
9617
  });
8682
9618
  }
9619
+ /**
9620
+ * The conditions (`hover:`) under which `state` applies and `base` does too, when both are the
9621
+ * same color: undefined when they are not, or `state` adds no condition but `dark:`, or styles
9622
+ * another element (`placeholder:`, `prose-a:`), whose color the base class does not set.
9623
+ */
9624
+ function conditionsBetween(state, base) {
9625
+ if (state.base !== base.base || state.modifier !== base.modifier) return void 0;
9626
+ if (!base.variants.every((variant) => state.variants.includes(variant))) return void 0;
9627
+ const added = state.variants.filter((v) => v !== "dark" && !base.variants.includes(v));
9628
+ if (added.some(stylesAnother)) return void 0;
9629
+ return added.length ? `${added.join(":")}:` : void 0;
9630
+ }
9631
+ /** Variants for a pseudo-element or other elements than the one the class is on. */
9632
+ const OTHER_ELEMENT = /* @__PURE__ */ new Set([
9633
+ "*",
9634
+ "**",
9635
+ "after",
9636
+ "backdrop",
9637
+ "before",
9638
+ "details-content",
9639
+ "file",
9640
+ "first-letter",
9641
+ "first-line",
9642
+ "marker",
9643
+ "placeholder",
9644
+ "selection"
9645
+ ]);
9646
+ /**
9647
+ * Whether a variant styles something else than the element: a pseudo-element (`placeholder:`),
9648
+ * children (`*:`), the typography plugin's elements (`prose-a:`), or an arbitrary selector that
9649
+ * goes on from `&` (`[&>svg]:`, `[&_a]:`, `[&::after]:`), not one that only narrows it
9650
+ * (`[&:hover]:`, `[.dark_&]:`).
9651
+ */
9652
+ function stylesAnother(variant) {
9653
+ if (OTHER_ELEMENT.has(variant) || variant.startsWith("prose-")) return true;
9654
+ const selector = /^\[(.*)\]$/.exec(variant)?.[1];
9655
+ if (!selector?.includes("&")) return false;
9656
+ const after = selector.slice(selector.lastIndexOf("&") + 1).replace(/\([^()]*\)|\[[^\]]*\]/g, "");
9657
+ return /[_>+~]|::/.test(after);
9658
+ }
9659
+ /**
9660
+ * When no token has the color's hue: add one, or keep the color and say why. Between JSX
9661
+ * children a `//` line is text, which suppresses nothing and renders, so that place gets the
9662
+ * `{/* *\/}` form.
9663
+ */
9664
+ const NO_TOKEN_HAS_HUE = "No token has this hue. Add a token for it to the theme, such as `--success` for a status color, or keep this color and give the reason on the line above: `// onsystem-disable-next-line no-hardcoded-color -- <reason>` (between JSX children, `{/* onsystem-disable-next-line no-hardcoded-color -- <reason> */}`).";
9665
+ /**
9666
+ * The most Tailwind v3 and v4 differ on one shade, in ΔE: 0.036 for fuchsia-600,
9667
+ * under 0.01 for the grays. A token this close to a class's shade, and nearer it
9668
+ * than to the family's other shades, can be a v3 project's copy of it.
9669
+ */
9670
+ const PALETTE_DRIFT = .037;
9671
+ /** Tailwind's default palette as colors: family → its shades, lightest first. */
9672
+ const PALETTE_SHADES = /* @__PURE__ */ new Map();
9673
+ /** A family's shades as colors, lightest first: `neutral` → 50, 100, … 950. */
9674
+ function shadesOf(family) {
9675
+ let shades = PALETTE_SHADES.get(family);
9676
+ if (!shades) {
9677
+ shades = [];
9678
+ for (const [shade, value] of Object.entries(TAILWIND_PALETTE[family] ?? {})) {
9679
+ const parsed = parseColor(value);
9680
+ if (parsed) shades.push([shade, parsed]);
9681
+ }
9682
+ PALETTE_SHADES.set(family, shades);
9683
+ }
9684
+ return shades;
9685
+ }
9686
+ /** Index of the color in `colors` nearest to `color`, or -1 when there is none. */
9687
+ function nearestOf(colors, color) {
9688
+ let index = -1;
9689
+ let best = Infinity;
9690
+ colors.forEach(([, value], i) => {
9691
+ const distance = colorDistance(color, value);
9692
+ if (distance < best) [index, best] = [i, distance];
9693
+ });
9694
+ return index;
9695
+ }
9696
+ /** The palette color nearest to `color` across every family (`neutral-500`). */
9697
+ function nearestPaletteColor(color) {
9698
+ const all = Object.keys(TAILWIND_PALETTE).flatMap((family) => shadesOf(family).map(([shade, value]) => [`${family}-${shade}`, value]));
9699
+ return all[nearestOf(all, color)]?.[0];
9700
+ }
9701
+ /**
9702
+ * Why a token that is not an exact match for a palette class (`key`, `neutral-600`)
9703
+ * is suggested but not applied, or undefined when it may be: it is nearer another
9704
+ * shade of the family (Dub's `content-default`, neutral-700, for `text-neutral-600`),
9705
+ * or it is the class's shade but further from it than Tailwind v3 and v4 are from
9706
+ * each other (`content-subtle`, neutral-500, for `text-slate-500`).
9707
+ */
9708
+ function heldBack(key, color, distance) {
9709
+ const [family = "", own = ""] = key.split("-");
9710
+ const shades = shadesOf(family);
9711
+ const from = shades.findIndex(([shade]) => shade === own);
9712
+ const to = nearestOf(shades, color);
9713
+ if (from === -1 || to === -1) return void 0;
9714
+ const steps = to - from;
9715
+ let what;
9716
+ let intended;
9717
+ if (steps) {
9718
+ const count = Math.abs(steps) === 1 ? "one shade" : `${String(Math.abs(steps))} shades`;
9719
+ const way = steps > 0 ? "darker" : "lighter";
9720
+ what = `${count} ${way} (${family}-${shades[to]?.[0] ?? ""})`;
9721
+ intended = `the ${way} color`;
9722
+ } else {
9723
+ if (distance < PALETTE_DRIFT) return void 0;
9724
+ const closest = nearestPaletteColor(color);
9725
+ what = closest && closest !== key ? `closest to ${closest}, not ${key}` : `a different color from ${key}`;
9726
+ intended = "that color";
9727
+ }
9728
+ return `That is ${what}, so it is not applied automatically: use it if ${intended} is intended, otherwise pick the semantic token that fits.`;
9729
+ }
9730
+ /** A token's value where the class applies: a `dark:` class takes its dark-mode value. */
9731
+ function valueIn(candidate, mode) {
9732
+ return mode && candidate.modes?.[mode] || candidate.color;
9733
+ }
9734
+ /** `content-subtle has this color (exact match), but it is meant for text.` */
9735
+ function meantForAnother(other, color, mode) {
9736
+ if (!other) return "";
9737
+ const { token } = other.candidate;
9738
+ const role = tokenRole(token);
9739
+ const how = other.distance < .02 ? "has this color" : "is close";
9740
+ const inMode = mode && token.modes?.[mode] !== void 0 ? ` in ${mode} mode` : "";
9741
+ return ` ${token.name} ${how}${inMode} (${formatDistance(color, valueIn(other.candidate, mode))}), but it is meant for ${role ? ROLE_WORDS[role] : "another role"}.`;
9742
+ }
8683
9743
  const ROLE_WORDS = {
8684
9744
  text: "text",
8685
9745
  surface: "backgrounds",
8686
9746
  line: "borders"
8687
9747
  };
8688
- /** Why no token replaces the color, naming the nearest one (`found`). */
8689
- function noMatch(reason, role, tinted, token, found, hueOff) {
9748
+ /**
9749
+ * Why no token replaces the color, naming the nearest one (`found`). A tinted color
9750
+ * with no token of its hue gets `NO_TOKEN_HAS_HUE` instead, which names none.
9751
+ */
9752
+ function noMatch(reason, role, found, hueOff) {
8690
9753
  if (reason === "status") return `Nearest is ${found}, a status color ${String(hueOff)}° away in hue; status colors are only suggested for their own hue.`;
8691
9754
  if (reason === "scoped") return `Nearest is ${found}, a token one part of the UI uses (not this one).`;
8692
- if (reason === "hue") {
8693
- if (!tinted) return `No gray token is close; nearest is ${found}, which is tinted.`;
8694
- return `No token has this hue; nearest is ${found}, ${isTinted(token) ? "another hue" : "a gray"}.`;
8695
- }
9755
+ if (reason === "hue") return `No gray token is close; nearest is ${found}, which is tinted.`;
8696
9756
  if (reason === "role" && role) return `No close token is meant for ${ROLE_WORDS[role]}; nearest is ${found}.`;
8697
9757
  return `No close token; nearest is ${found}.`;
8698
9758
  }
@@ -8758,6 +9818,7 @@ function lengthRule(id, description, spec) {
8758
9818
  if (negative && !NEGATIVE_PREFIXES.has(utility.prefix)) continue;
8759
9819
  const replacement = lengthReplacement(context, spec.category, px, utility.prefix);
8760
9820
  if (!replacement || unnamed(replacement)) continue;
9821
+ if (spec.category === "spacing" && Math.abs(px) <= 1 && (!replacement.exact || negative)) continue;
8761
9822
  const fixed = withBase({
8762
9823
  ...utility,
8763
9824
  negative
@@ -8791,6 +9852,7 @@ function lengthRule(id, description, spec) {
8791
9852
  if (negative && !NEGATIVE_PREFIXES.has(prefix)) continue;
8792
9853
  const replacement = values.length === 1 ? lengthReplacement(context, spec.category, px, prefix) : void 0;
8793
9854
  if (replacement && unnamed(replacement)) continue;
9855
+ if (replacement && spec.category === "spacing" && Math.abs(px) <= 1 && (!replacement.exact || negative)) continue;
8794
9856
  const cls = replacement && `${negative ? "-" : ""}${replacement.cls}`;
8795
9857
  const cssVar = replacement?.cssVar && (negative ? `calc(${replacement.cssVar} * -1)` : replacement.cssVar);
8796
9858
  const shown = number !== void 0 ? `${name}: ${raw}` : `${name}: "${raw}"`;
@@ -8963,13 +10025,14 @@ function findingKey(diagnostic) {
8963
10025
  const text = diagnostic.source.replace(/\s+/g, " ").trim();
8964
10026
  return (/^(['"`])(.*)\1$/.exec(text)?.[2] ?? text).slice(0, 200);
8965
10027
  }
8966
- /** The baseline's entries, or undefined when the file does not exist. */
10028
+ /** The baseline, or undefined when the file does not exist. */
8967
10029
  function readBaseline(file) {
8968
10030
  let text;
8969
10031
  try {
8970
10032
  text = fs.readFileSync(file, "utf8");
8971
10033
  } catch (error) {
8972
- if (error.code === "ENOENT") return void 0;
10034
+ const { code } = error;
10035
+ if (code === "ENOENT" || code === "ENOTDIR") return void 0;
8973
10036
  throw new ConfigError(`Could not read baseline ${file}: ${error.message}`);
8974
10037
  }
8975
10038
  const invalid = (why) => new ConfigError(`${file} is not a valid onsystem baseline (${why}). Fix it, or delete it and run check --update-baseline.`);
@@ -8981,6 +10044,8 @@ function readBaseline(file) {
8981
10044
  }
8982
10045
  if (!isObject(parsed) || parsed.version !== 1) throw invalid("expected \"version\": 1");
8983
10046
  if (!isObject(parsed.files)) throw invalid("\"files\" must be an object");
10047
+ if (parsed.onsystem !== void 0 && typeof parsed.onsystem !== "string") throw invalid("\"onsystem\" must be a version string");
10048
+ if (parsed.rules !== void 0 && !(Array.isArray(parsed.rules) && parsed.rules.every((rule) => typeof rule === "string"))) throw invalid("\"rules\" must be a list of rule ids");
8984
10049
  const entries = /* @__PURE__ */ new Map();
8985
10050
  for (const [name, rules] of Object.entries(parsed.files)) {
8986
10051
  if (!isObject(rules)) throw invalid(`"${name}" must map rules to findings`);
@@ -8996,7 +10061,11 @@ function readBaseline(file) {
8996
10061
  }
8997
10062
  entries.set(name, byRule);
8998
10063
  }
8999
- return entries;
10064
+ return {
10065
+ entries,
10066
+ onsystem: parsed.onsystem,
10067
+ rules: parsed.rules ? new Set(parsed.rules) : void 0
10068
+ };
9000
10069
  }
9001
10070
  /**
9002
10071
  * Removes the findings the baseline accepts for `file`. Within a file and
@@ -9057,6 +10126,71 @@ function updateBaseline(previous, checked, root, disabled = /* @__PURE__ */ new
9057
10126
  }
9058
10127
  return next;
9059
10128
  }
10129
+ /**
10130
+ * The rules an update records as run: those that `ran` now, those the previous baseline lists,
10131
+ * and those with entries in `next`, since a rule with recorded findings ran when they were
10132
+ * recorded. Undefined, as in a baseline written before onsystem recorded rules, when the
10133
+ * previous baseline does not list them and the update may have left out files it covers: the
10134
+ * run checked only a folder or files below the root (`wholeRoot` false), or `next` keeps
10135
+ * entries of files the run did not check. Whatever ran over those files was not recorded, so a
10136
+ * list built from this run could say a rule did not run where it did.
10137
+ */
10138
+ function rulesRun(previous, next, checked, ran, wholeRoot) {
10139
+ if (previous && !previous.rules && (!wholeRoot || [...next.keys()].some((file) => !checked.has(file)))) return;
10140
+ const known = new Set(RULE_IDS);
10141
+ const rules = /* @__PURE__ */ new Set([...previous?.rules ?? [], ...ran]);
10142
+ for (const byRule of next.values()) for (const rule of byRule.keys()) if (known.has(rule)) rules.add(rule);
10143
+ return [...rules];
10144
+ }
10145
+ /**
10146
+ * The baseline without the entries a run over `checked` (file → its findings) shows no longer
10147
+ * occur: a checked file keeps at most as many of each finding as it still has, plus its entries
10148
+ * for `disabled` rules, and files the run did not check keep theirs while they still exist under
10149
+ * `root`. Nothing is added, so a new finding stays new, unlike with `updateBaseline`.
10150
+ */
10151
+ function pruneBaseline(previous, checked, root, disabled) {
10152
+ const next = /* @__PURE__ */ new Map();
10153
+ for (const [file, rules] of previous) {
10154
+ const diagnostics = checked.get(file);
10155
+ if (!diagnostics) {
10156
+ if (fs.existsSync(path.resolve(root, file))) next.set(file, rules);
10157
+ continue;
10158
+ }
10159
+ const found = /* @__PURE__ */ new Map();
10160
+ for (const diagnostic of diagnostics) {
10161
+ const id = `${diagnostic.ruleId}\0${findingKey(diagnostic)}`;
10162
+ found.set(id, (found.get(id) ?? 0) + 1);
10163
+ }
10164
+ const off = disabled(file);
10165
+ const kept = /* @__PURE__ */ new Map();
10166
+ for (const [rule, sources] of rules) {
10167
+ if (off.has(rule)) {
10168
+ kept.set(rule, sources);
10169
+ continue;
10170
+ }
10171
+ const left = /* @__PURE__ */ new Map();
10172
+ for (const [source, count] of sources) {
10173
+ const still = Math.min(count, found.get(`${rule}\0${source}`) ?? 0);
10174
+ if (still) left.set(source, still);
10175
+ }
10176
+ if (left.size) kept.set(rule, left);
10177
+ }
10178
+ if (kept.size) next.set(file, kept);
10179
+ }
10180
+ return next;
10181
+ }
10182
+ /**
10183
+ * New findings of rules that did not run when the baseline was recorded, counted by rule: a
10184
+ * rule that starts to run (an upgrade that finds the tokens it needs, or a rule turned on)
10185
+ * reports what was in the code all along. Empty when the baseline does not say which rules ran.
10186
+ */
10187
+ function unrecordedRules(baseline, diagnostics) {
10188
+ const counts = /* @__PURE__ */ new Map();
10189
+ if (!baseline.rules) return counts;
10190
+ const rules = new Set(RULE_IDS);
10191
+ for (const { ruleId } of diagnostics) if (rules.has(ruleId) && !baseline.rules.has(ruleId)) counts.set(ruleId, (counts.get(ruleId) ?? 0) + 1);
10192
+ return counts;
10193
+ }
9060
10194
  function countBaseline(entries) {
9061
10195
  let findings = 0;
9062
10196
  for (const rules of entries.values()) for (const sources of rules.values()) for (const count of sources.values()) findings += count;
@@ -9065,8 +10199,11 @@ function countBaseline(entries) {
9065
10199
  files: entries.size
9066
10200
  };
9067
10201
  }
9068
- /** Writes the baseline sorted at every level, so it diffs cleanly in review. */
9069
- function writeBaseline(file, entries) {
10202
+ /**
10203
+ * Writes the baseline sorted at every level, so it diffs cleanly in review, with the onsystem
10204
+ * version and the rules that ran when given, and creates its folder.
10205
+ */
10206
+ function writeBaseline(file, entries, about = {}) {
9070
10207
  const sorted = (map) => [...map].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
9071
10208
  const files = {};
9072
10209
  for (const [name, rules] of sorted(entries)) {
@@ -9080,9 +10217,12 @@ function writeBaseline(file, entries) {
9080
10217
  }
9081
10218
  const baseline = {
9082
10219
  version: 1,
10220
+ ...about.onsystem === void 0 ? {} : { onsystem: about.onsystem },
10221
+ ...about.rules === void 0 ? {} : { rules: [...new Set(about.rules)].sort() },
9083
10222
  files
9084
10223
  };
9085
10224
  try {
10225
+ fs.mkdirSync(path.dirname(file), { recursive: true });
9086
10226
  fs.writeFileSync(file, `${JSON.stringify(baseline, null, 2)}\n`);
9087
10227
  } catch (error) {
9088
10228
  throw new ConfigError(`Could not write baseline ${file}: ${error.message}`);
@@ -9096,7 +10236,7 @@ function isObject(value) {
9096
10236
  var name = "onsystem";
9097
10237
  //#endregion
9098
10238
  //#region src/version.ts
9099
- const VERSION = "0.6.0";
10239
+ const VERSION = "0.7.0";
9100
10240
  const NAME = name;
9101
10241
  //#endregion
9102
10242
  //#region src/lint/sarif.ts
@@ -9260,6 +10400,7 @@ function formatDiagnostics(results, format, options = {}) {
9260
10400
  if (fixedHint) lines.push(dim(fixedHint, useColor));
9261
10401
  return lines.join("\n");
9262
10402
  }
10403
+ /** Escapes a workflow command's message, so `%` and line breaks reach GitHub as written. */
9263
10404
  function escapeData(value) {
9264
10405
  return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
9265
10406
  }
@@ -9763,6 +10904,55 @@ function searchComponents(index, query, limit = 5) {
9763
10904
  }));
9764
10905
  }
9765
10906
  //#endregion
10907
+ //#region src/tokens/imports.ts
10908
+ /** Enough stylesheets for a theme split over files, without reading a whole design system. */
10909
+ const MAX_IMPORTED = 50;
10910
+ /**
10911
+ * The specifiers a stylesheet `@import`s: `@import "./theme.css" layer(base)`,
10912
+ * `@import url(theme.css)`, `@import "@acme/theme"`. Empty for CSS that does not parse.
10913
+ */
10914
+ function cssImports(css) {
10915
+ let root;
10916
+ try {
10917
+ root = postcss.parse(css);
10918
+ } catch {
10919
+ return [];
10920
+ }
10921
+ const out = [];
10922
+ for (const node of root.nodes) {
10923
+ if (node.type !== "atrule" || node.name !== "import") continue;
10924
+ const match = /^\s*(?:url\(\s*)?(?:"([^"]+)"|'([^']+)'|([^\s"')]+)\s*\))/.exec(node.params);
10925
+ const specifier = match?.[1] ?? match?.[2] ?? match?.[3];
10926
+ if (specifier) out.push(specifier);
10927
+ }
10928
+ return out;
10929
+ }
10930
+ /**
10931
+ * The stylesheets `files` import, and the ones those import in turn, each once and in the
10932
+ * order they are imported. The files themselves are not repeated.
10933
+ */
10934
+ function importedStylesheets(files, resolve) {
10935
+ const seen = new Set(files);
10936
+ const out = [];
10937
+ const visit = (file) => {
10938
+ let text;
10939
+ try {
10940
+ text = fs.readFileSync(file, "utf8");
10941
+ } catch {
10942
+ return;
10943
+ }
10944
+ for (const specifier of cssImports(text)) {
10945
+ const target = resolve(specifier, file);
10946
+ if (!target || seen.has(target) || out.length >= MAX_IMPORTED) continue;
10947
+ seen.add(target);
10948
+ out.push(target);
10949
+ visit(target);
10950
+ }
10951
+ };
10952
+ for (const file of files) visit(file);
10953
+ return out;
10954
+ }
10955
+ //#endregion
9766
10956
  //#region src/util/log.ts
9767
10957
  const stderrLogger = {
9768
10958
  info: (message) => process.stderr.write(`[onsystem] ${message}\n`),
@@ -9887,11 +11077,94 @@ function matchUtility(base, tokens) {
9887
11077
  return key ? tokens.find((t) => t.category === "color" && t.tailwind === key) : void 0;
9888
11078
  }
9889
11079
  /**
11080
+ * Where the design system's files may really be, links resolved: the project, the workspace
11081
+ * and the git repository it is in, the folder a design-system package's config stays in, and
11082
+ * packages reached through a `node_modules` folder in those, which pnpm can link from a store
11083
+ * elsewhere. A glob follows links, and a config can name `../` and absolute paths, so without
11084
+ * this a cloned repository could have the tools return any file on the machine
11085
+ * (`docs/credentials.md` linked to `~/.aws/credentials`).
11086
+ */
11087
+ var Containment = class {
11088
+ /** The folders, as given. */
11089
+ dirs;
11090
+ /** The same folders, links resolved. */
11091
+ roots;
11092
+ reals = /* @__PURE__ */ new Map();
11093
+ /** Files left out, as they were found. */
11094
+ outside = /* @__PURE__ */ new Set();
11095
+ constructor(config) {
11096
+ const around = [
11097
+ workspaceRootOf(config.root),
11098
+ repositoryOf(config.root),
11099
+ config.base,
11100
+ config.designSystemPackage?.boundary
11101
+ ].filter((dir) => dir !== void 0 && !isHomeOrAbove(dir));
11102
+ this.dirs = unique([config.root, ...around].map((dir) => path.resolve(dir)));
11103
+ this.roots = Promise.all(this.dirs.map((dir) => this.real(dir))).then((reals) => reals.filter((real) => real !== void 0));
11104
+ }
11105
+ /** Where `file` really is, links resolved; undefined when it is not there. */
11106
+ real(file) {
11107
+ let real = this.reals.get(file);
11108
+ if (!real) {
11109
+ real = fsp.realpath(file).catch(() => void 0);
11110
+ this.reals.set(file, real);
11111
+ }
11112
+ return real;
11113
+ }
11114
+ /**
11115
+ * `files` whose real path is inside, in their order, one path per real file: the shortest,
11116
+ * so a folder that links back to its parent does not add its files again.
11117
+ */
11118
+ async filter(files) {
11119
+ const roots = await this.roots;
11120
+ const reals = await Promise.all(files.map((file) => this.real(file)));
11121
+ const kept = /* @__PURE__ */ new Map();
11122
+ files.forEach((file, i) => {
11123
+ const real = reals[i];
11124
+ if (real === void 0) return;
11125
+ if (!(roots.some((root) => isInside(root, real)) || isInstalled(real) && this.inNodeModules(file))) {
11126
+ this.outside.add(file);
11127
+ return;
11128
+ }
11129
+ const previous = kept.get(real);
11130
+ if (previous === void 0 || file.length < previous.length) kept.set(real, file);
11131
+ });
11132
+ return [...kept.values()];
11133
+ }
11134
+ /**
11135
+ * Whether `file`, as found, is in a package in a `node_modules` folder of the project, the
11136
+ * workspace or the repository, as one pnpm links from its store is. A link elsewhere in the
11137
+ * project to another project's `node_modules` is not.
11138
+ */
11139
+ inNodeModules(file) {
11140
+ const found = path.resolve(file);
11141
+ return this.dirs.some((dir) => isInside(dir, found) && isInstalled(path.relative(dir, found)));
11142
+ }
11143
+ };
11144
+ /** Whether a path is a file of a package in `node_modules`, not of `.cache` or `.bin` there. */
11145
+ function isInstalled(file) {
11146
+ const parts = file.split(/[\\/]/);
11147
+ const at = parts.lastIndexOf("node_modules");
11148
+ return at !== -1 && at < parts.length - 2 && !(parts[at + 1] ?? ".").startsWith(".");
11149
+ }
11150
+ /** The nearest folder at or above `dir` with a `.git`: the repository it is in. */
11151
+ function repositoryOf(dir) {
11152
+ for (let current = path.resolve(dir);; current = path.dirname(current)) {
11153
+ if (fs.existsSync(path.join(current, ".git"))) return current;
11154
+ if (current === path.dirname(current)) return void 0;
11155
+ }
11156
+ }
11157
+ /** What `inspect` and the server's log say about files that lead outside the project. */
11158
+ function outsideWarning(root, files) {
11159
+ const shown = files.map((file) => relativePath(root, file)).sort();
11160
+ return `Not read: ${shown.length === 1 ? shown[0] ?? "" : shown.length <= 5 ? `${shown.slice(0, -1).join(", ")} and ${shown.at(-1) ?? ""}` : `${shown.slice(0, 5).join(", ")} and ${shown.length - 5} more`} ${shown.length === 1 ? "leads" : "lead"} outside the project. Links resolved, the design system's files must be in the project, the workspace or git repository it is in, a package installed in their node_modules, or a package that declares its config (run onsystem init in it).`;
11161
+ }
11162
+ /**
9890
11163
  * Absolute paths matching `patterns` under the root. `node_modules` is skipped
9891
11164
  * even when `exclude` replaces the defaults, except by patterns that name it
9892
- * (tokens published as a package).
11165
+ * (tokens published as a package). Files that lead outside the project are left out.
9893
11166
  */
9894
- async function find(config, patterns) {
11167
+ async function find(config, patterns, contain = new Containment(config)) {
9895
11168
  const options = {
9896
11169
  cwd: config.root,
9897
11170
  absolute: true,
@@ -9901,7 +11174,7 @@ async function find(config, patterns) {
9901
11174
  const named = patterns.filter((p) => p.includes("node_modules"));
9902
11175
  const outside = patterns.filter((p) => !p.includes("node_modules") && p.startsWith("../"));
9903
11176
  const rest = patterns.filter((p) => !p.includes("node_modules") && !p.startsWith("../"));
9904
- return unique((await Promise.all([
11177
+ const found = await Promise.all([
9905
11178
  rest.length ? glob(rest, {
9906
11179
  ...options,
9907
11180
  ignore: unique(["**/node_modules/**", ...exclude])
@@ -9923,7 +11196,8 @@ async function find(config, patterns) {
9923
11196
  ])
9924
11197
  });
9925
11198
  })
9926
- ])).flat()).sort();
11199
+ ]);
11200
+ return (await contain.filter(unique(found.flat()))).sort();
9927
11201
  }
9928
11202
  /** The design system's own component files, as absolute paths. */
9929
11203
  function componentFiles(config) {
@@ -9934,28 +11208,51 @@ async function tokenFiles(config) {
9934
11208
  return (await resolveFiles(config)).tokens.map((t) => t.file);
9935
11209
  }
9936
11210
  async function resolveFiles(config) {
9937
- const components = await find(config, config.components);
9938
- const docs = await find(config, config.docs);
11211
+ const contain = new Containment(config);
11212
+ const components = await find(config, config.components, contain);
11213
+ const docs = await find(config, config.docs, contain);
9939
11214
  const tokens = [];
9940
11215
  const seen = /* @__PURE__ */ new Set();
11216
+ const isNew = async (file) => {
11217
+ const real = await contain.real(file) ?? path.resolve(file);
11218
+ if (seen.has(real)) return false;
11219
+ seen.add(real);
11220
+ return true;
11221
+ };
9941
11222
  const add = async (sources) => {
9942
- for (const source of sources) for (const file of await find(config, [source.path])) {
9943
- if (seen.has(file)) continue;
9944
- seen.add(file);
9945
- tokens.push({
9946
- file,
9947
- prefix: source.prefix
9948
- });
9949
- }
11223
+ for (const source of sources) for (const file of await find(config, [source.path], contain)) if (await isNew(file)) tokens.push({
11224
+ file,
11225
+ prefix: source.prefix
11226
+ });
9950
11227
  };
9951
11228
  await add(config.tokens.filter((t) => !t.fallback));
11229
+ if (config.tokensDetected) {
11230
+ const found = tokens.map((t) => path.resolve(t.file));
11231
+ for (const file of await contain.filter(linkedStylesheets(config, found))) if (await isNew(file)) tokens.push({ file });
11232
+ }
9952
11233
  if (!tokens.length) await add(config.tokens.filter((t) => t.fallback));
9953
11234
  return {
9954
11235
  components,
9955
11236
  tokens,
9956
- docs
11237
+ docs,
11238
+ outside: [...contain.outside]
9957
11239
  };
9958
11240
  }
11241
+ /**
11242
+ * The stylesheets zero config reads besides the ones it found: those they `@import` (by a
11243
+ * path, or from a workspace package: `@import "@acme/theme"`), then those that set the colors
11244
+ * a Tailwind v3 config maps, in the workspace package of a shared preset it reads (Dub's
11245
+ * `@dub/tailwind-config` and its `themes.css`), with what they import. May repeat a file.
11246
+ */
11247
+ function linkedStylesheets(config, found) {
11248
+ const resolve = stylesheetResolver(config.root);
11249
+ const preset = config.tailwindConfig ? presetStylesheets(readTailwindColors(config.tailwindConfig, presetResolver(config.root)), config.root) : [];
11250
+ return [
11251
+ ...importedStylesheets(found.filter((f) => f.endsWith(".css")), resolve),
11252
+ ...preset,
11253
+ ...importedStylesheets(preset, resolve)
11254
+ ];
11255
+ }
9959
11256
  /** Size and mtime of files, each read once per build. */
9960
11257
  var FileStats = class {
9961
11258
  root;
@@ -9977,11 +11274,11 @@ var FileStats = class {
9977
11274
  /**
9978
11275
  * Everything that, when changed, invalidates the cached model: the tracked
9979
11276
  * files, `dependencies` (project files the components import and the tsconfig
9980
- * chain, known after extraction), the lockfile and the config.
11277
+ * chain, known after extraction), the lockfile, the config and the files left out.
9981
11278
  */
9982
11279
  async function cacheKey(config, files, dependencies, stats = new FileStats(config.root)) {
9983
11280
  const extra = [
9984
- "tsconfig.json",
11281
+ ...PROJECT_CONFIG_FILES,
9985
11282
  "package.json",
9986
11283
  "pnpm-lock.yaml",
9987
11284
  "package-lock.json",
@@ -9996,9 +11293,11 @@ async function cacheKey(config, files, dependencies, stats = new FileStats(confi
9996
11293
  ...dependencies
9997
11294
  ]);
9998
11295
  const { rules: _rules, overrides: _overrides, ...relevant } = config;
11296
+ const outside = files.outside.map((file) => relativePath(config.root, file)).sort();
9999
11297
  return createHash("sha256").update(JSON.stringify({
10000
11298
  version: VERSION,
10001
11299
  config: relevant,
11300
+ outside,
10002
11301
  stats: await stats.of(all)
10003
11302
  })).digest("hex");
10004
11303
  }
@@ -10047,6 +11346,7 @@ async function buildModel(config, options = {}) {
10047
11346
  });
10048
11347
  const docs = [];
10049
11348
  const warnings = [...extracted.warnings];
11349
+ if (files.outside.length) warnings.push(outsideWarning(config.root, files.outside));
10050
11350
  for (const file of files.docs) try {
10051
11351
  docs.push(parseDoc(await fsp.readFile(file, "utf8"), relativePath(config.root, file)));
10052
11352
  } catch (error) {
@@ -10058,11 +11358,11 @@ async function buildModel(config, options = {}) {
10058
11358
  const libraries = await loadLibraries(config);
10059
11359
  warnings.push(...libraries.warnings);
10060
11360
  tokens.tokens.push(...libraries.tokens);
10061
- const tailwind = config.tailwindConfig ? readTailwindColors(config.tailwindConfig, moduleResolver(config.root)) : void 0;
10062
- if (tailwind) applyTailwindColors(tokens.tokens, tailwind.colors);
11361
+ const tailwind = config.tailwindConfig ? readTailwindColors(config.tailwindConfig, presetResolver(config.root)) : void 0;
11362
+ if (tailwind) applyTailwindColors(tokens.tokens, tailwind.colors, tailwind.rgb);
10063
11363
  let radiiFiles = [];
10064
11364
  if (config.tailwindConfig) {
10065
- const radii = readTailwindRadii(config.tailwindConfig, moduleResolver(config.root));
11365
+ const radii = readTailwindRadii(config.tailwindConfig, presetResolver(config.root));
10066
11366
  applyTailwindRadii(tokens.tokens, radii.radii, config.tailwindConfig);
10067
11367
  radiiFiles = radii.files;
10068
11368
  }
@@ -10191,7 +11491,7 @@ var DesignSystemHost = class {
10191
11491
  configFile,
10192
11492
  designSystemPackage && path.join(designSystemPackage.dir, "package.json"),
10193
11493
  ...DETECTION_FILES.map((f) => path.join(root, f)),
10194
- path.resolve(root, tsconfig ?? "tsconfig.json"),
11494
+ ...tsconfig ? [path.resolve(root, tsconfig)] : PROJECT_CONFIG_FILES.map((f) => path.join(root, f)),
10195
11495
  ...readProjectConfig(root, tsconfig).configFiles.filter(isProjectFile)
10196
11496
  ].filter((f) => Boolean(f)).map((f) => path.resolve(f));
10197
11497
  for (const file of files) this.watchedFiles.add(file);
@@ -10285,4 +11585,4 @@ var DesignSystemHost = class {
10285
11585
  }
10286
11586
  };
10287
11587
  //#endregion
10288
- export { findVariantDefinitions as $, toDtcg as A, DEFAULT_DOCS as B, updateBaseline as C, loadTokens as D, TokenIndex as E, attachDocs as F, RULE_IDS as G, DEFAULT_TEST_EXCLUDE as H, parseDoc as I, hasOwnConfig as J, configJsonSchema as K, extractComponents as L, parseDtcgTokens as M, parseDesignMd as N, mergeTokens as O, parseCssTokens as P, formatPx as Q, CONFIG_FILES as R, readBaseline as S, RULES as T, DEFAULT_TEST_FOLDERS as U, DEFAULT_SEVERITY as V, LEGACY_CONFIG_FILES as W, loadConfigWith as X, loadConfig as Y, rulesFor as Z, VERSION as _, relativePath as _t, fingerprint as a, detectProject as at, applyBaseline as b, silentLogger as c, isWorkspaceRoot as ct, searchComponents as d, closest as dt, LIBRARY_IDS as et, applyFixes as f, plural$1 as ft, NAME as g, matchesGlob as gt, formatDiagnostics as h, isInside as ht, componentFiles as i, declaredConfig as it, TOKEN_CATEGORIES as j, toDesignMd as k, stderrLogger as l, workspacePackageDirs as lt, uncheckedNotice as m, unique as mt, DesignSystemHost as n, PACKAGE_FIELD as nt, loadDesignSystem as o, findDesignSystemPackage as ot, checkSource as p, truncate as pt, defineConfig as q, buildModel as r, PackageFinder as rt, tokenFiles as s, isDesignSystemName as st, DesignSystem as t, libraryAdapter as tt, buildSearchIndex as u, workspacePatterns as ut, BASELINE_FILE as v, toPosix as vt, writeBaseline as w, countBaseline as x, LEGACY_BASELINE_FILE as y, ConfigError as z };
11588
+ export { loadConfig as $, TokenIndex as A, parseDoc as B, pruneBaseline as C, relativePath as Ct, updateBaseline as D, unrecordedRules as E, TOKEN_CATEGORIES as F, DEFAULT_SEVERITY as G, CONFIG_FILES as H, parseDtcgTokens as I, LEGACY_CONFIG_FILES as J, DEFAULT_TEST_EXCLUDE as K, parseDesignMd as L, mergeTokens as M, toDesignMd as N, writeBaseline as O, toDtcg as P, hasOwnConfig as Q, parseCssTokens as R, countBaseline as S, matchesGlob as St, rulesRun as T, defaultProjectConfig as Tt, ConfigError as U, extractComponents as V, DEFAULT_DOCS as W, configJsonSchema as X, RULE_IDS as Y, defineConfig as Z, NAME as _, closest as _t, fingerprint as a, LIBRARY_IDS as at, LEGACY_BASELINE_FILE as b, unique as bt, silentLogger as c, PACKAGE_FIELD as ct, searchComponents as d, detectProject as dt, loadConfigWith as et, applyFixes as f, findDesignSystemPackage as ft, formatDiagnostics as g, workspacePatterns as gt, escapeData as h, workspacePackageDirs as ht, componentFiles as i, LIBRARIES as it, loadTokens as j, RULES as k, stderrLogger as l, PackageFinder as lt, uncheckedNotice as m, isWorkspaceRoot as mt, DesignSystemHost as n, formatPx as nt, loadDesignSystem as o, libraryTitle as ot, checkSource as p, isDesignSystemName as pt, DEFAULT_TEST_FOLDERS as q, buildModel as r, findVariantDefinitions as rt, tokenFiles as s, notReadYet as st, DesignSystem as t, rulesFor as tt, buildSearchIndex as u, declaredConfig as ut, VERSION as v, plural$1 as vt, readBaseline as w, toPosix as wt, applyBaseline as x, isInside as xt, BASELINE_FILE as y, truncate as yt, attachDocs as z };