onsystem 0.6.0 → 0.8.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 { clampChroma, colorsNamed, converter, differenceEuclidean, formatHex, parse } from "culori";
7
+ import os from "node:os";
8
+ import { clampChroma, colorsNamed, converter, differenceEuclidean, formatHex, parse, wcagContrast } 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,
@@ -107,6 +137,12 @@ function slashGlob(pattern) {
107
137
  if (!pattern.includes("/")) return pattern.replaceAll("\\", "/");
108
138
  return pattern.replace(/\\(?![()[\]{}*?!+@])/g, "/");
109
139
  }
140
+ /**
141
+ * The files `check` and `check_ui` read: `.tsx`, `.jsx`, `.ts`, `.js` and the `.mts`, `.cts`,
142
+ * `.mjs` and `.cjs` forms, in any case. CSS, Markdown or JSON parsed as TSX gives hundreds of
143
+ * syntax errors (209 for a shadcn/ui `app/globals.css`).
144
+ */
145
+ const SCRIPT_FILE$1 = /\.(?:[jt]sx?|[cm][jt]s)$/i;
110
146
  /** Path relative to `root`, always with forward slashes. */
111
147
  function relativePath(root, file) {
112
148
  return toPosix(path.relative(root, file));
@@ -116,6 +152,24 @@ function isInside(root, file) {
116
152
  const rel = path.relative(root, file);
117
153
  return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
118
154
  }
155
+ /**
156
+ * Whether `dir` is the home folder, a folder that holds it, or a filesystem root, links
157
+ * resolved. None of them is a project's workspace or repository, though a stray
158
+ * `~/package.json` with `workspaces`, or a repository of dotfiles, makes one look like it, and
159
+ * from there a `../` path reaches `~/.aws/credentials`.
160
+ */
161
+ function isHomeOrAbove(dir) {
162
+ const home = os.homedir();
163
+ const holdsHome = (d, h) => d === path.parse(d).root || isInside(d, h);
164
+ return holdsHome(path.resolve(dir), path.resolve(home)) || holdsHome(realOrSelf(dir), realOrSelf(home));
165
+ }
166
+ function realOrSelf(p) {
167
+ try {
168
+ return fs.realpathSync(p);
169
+ } catch {
170
+ return path.resolve(p);
171
+ }
172
+ }
119
173
  /** The static directory part of a glob (`components/ui/**\/*.tsx` → `components/ui`). */
120
174
  function globBase(glob) {
121
175
  const parts = toPosix(glob).split("/");
@@ -143,6 +197,16 @@ function matchesGlob(file, pattern) {
143
197
  return regex.test(file.replace(/^\.\//, ""));
144
198
  }
145
199
  const globRegExps = /* @__PURE__ */ new Map();
200
+ /**
201
+ * Whether a root-relative path is one a glob matches, or is in a folder it matches, as a
202
+ * glob's `ignore` leaves a matching folder out: `app/legacy`, `app/legacy/`, `./app/legacy` and
203
+ * `app/legacy/**` all cover `app/legacy/old.tsx`, and `**\/legacy` covers every legacy folder.
204
+ * How `exclude` patterns are matched, wherever they apply.
205
+ */
206
+ function coveredByGlob(file, pattern) {
207
+ const glob = pattern.replace(/^\.\//, "").replace(/\/+$/, "");
208
+ return matchesGlob(file, glob) || matchesGlob(file, `${glob}/**`);
209
+ }
146
210
  function globSource(pattern) {
147
211
  let out = "";
148
212
  for (let i = 0; i < pattern.length; i++) {
@@ -534,7 +598,9 @@ const STYLESHEETS = [
534
598
  * workspace sources, read the same way or through their own `components.json`,
535
599
  * or, for a package without `exports` that apps import by path
536
600
  * (`@acme/ui/primitives/button`), from the files the root's code imports. They
537
- * are added to what `components.json` found, not skipped for it.
601
+ * are added to what `components.json` found, not skipped for it. One installed
602
+ * from a registry is read through its `exports` alone, when they point at the
603
+ * component sources it publishes, not from `dist/` back to `src/`.
538
604
  * 4. A flat folder of components that wrap a primitives library, the way React
539
605
  * Aria's Tailwind starter ships them (`src/Button.tsx`, `src/Checkbox.tsx`).
540
606
  *
@@ -542,6 +608,10 @@ const STYLESHEETS = [
542
608
  * (`designSystems`). A candidate whose globs match no file is skipped. Returns undefined
543
609
  * when nothing applies, so the shadcn defaults stay in force.
544
610
  *
611
+ * Next to the ui folder `components.json` names, the folders `npx shadcn add` copies other
612
+ * registries' code into (its `registries`: `@signoff-ui` → `components/signoff-ui`) are
613
+ * read-only design systems, after all the others ({@link fromRegistries}).
614
+ *
545
615
  * `declared` is the dependency whose config the root takes on ({@link findDesignSystemPackage}):
546
616
  * it is read first among the dependencies in step 3, whatever its name, and also when it is
547
617
  * installed from a registry rather than a workspace package.
@@ -554,8 +624,9 @@ function detectProject(root, tsconfig, trace, declared, packages = new PackageFi
554
624
  });
555
625
  const found = [];
556
626
  const shadcn = readJson$1(path.join(root, "components.json"));
627
+ const project = shadcn ? readProjectConfig(root, tsconfig) : void 0;
557
628
  let why = "no ui or components alias";
558
- const fromShadcn = shadcn ? fromComponentsJson(root, root, shadcn, readProjectConfig(root, tsconfig), packages, (r) => {
629
+ const fromShadcn = shadcn && project ? fromComponentsJson(root, root, shadcn, project, packages, (r) => {
559
630
  why = r;
560
631
  }) : void 0;
561
632
  if (!shadcn) step("components.json", false, fs.existsSync(path.join(root, "components.json")) ? "not valid JSON" : "not found");
@@ -591,21 +662,23 @@ function detectProject(root, tsconfig, trace, declared, packages = new PackageFi
591
662
  let imported;
592
663
  for (const name of names) {
593
664
  const own = name === declared?.name ? declared : void 0;
594
- const kind = own?.installed ? "installed" : "workspace";
665
+ const workspaceDir = own ? void 0 : packages.find(name);
666
+ const installedDir = own || workspaceDir ? void 0 : packages.installed(name);
667
+ const kind = own?.installed ?? installedDir !== void 0 ? "installed" : "workspace";
595
668
  const candidate = `${kind} package ${name}`;
596
669
  if (found.some((f) => f.package === name)) {
597
670
  step(candidate, false, "not added twice: components.json points into it");
598
671
  continue;
599
672
  }
600
- const dir = own ? own.dir : packages.find(name);
673
+ const dir = own?.dir ?? workspaceDir ?? installedDir;
601
674
  const pkg = dir ? readJson$1(path.join(dir, "package.json")) : void 0;
602
675
  if (!dir || !pkg) {
603
- step(candidate, false, "not a workspace package: an installed package is compiled, and only sources are read");
676
+ step(candidate, false, "not a workspace package, and not installed");
604
677
  continue;
605
678
  }
606
679
  const reasons = [];
607
680
  const note = (r) => reasons.push(r);
608
- const result = fromPackage(root, dir, pkg, note) ?? fromPackageComponentsJson(root, dir, pkg, packages, note) ?? fromImports(root, dir, name, (imported ??= importedSubpaths(root, names)).get(name), note);
681
+ const result = installedDir ? fromPackage(root, dir, pkg, note, true) : fromPackage(root, dir, pkg, note) ?? fromPackageComponentsJson(root, dir, pkg, packages, note) ?? fromImports(root, dir, name, (imported ??= importedSubpaths(root, names)).get(name), note);
609
682
  if (result && matchesAny(root, result)) {
610
683
  found.push({
611
684
  ...result,
@@ -616,11 +689,28 @@ function detectProject(root, tsconfig, trace, declared, packages = new PackageFi
616
689
  step(candidate, true, `${relativePath(root, dir)}, ${countFiles(root, result)}`);
617
690
  } else step(candidate, false, result ? "its components match no file" : unique(reasons).join("; ") || "nothing to read");
618
691
  }
692
+ const registries = shadcn && project && fromShadcn && !withDefaults ? fromRegistries(root, shadcn, fromShadcn, found, project, step) : [];
619
693
  if (found.length) {
620
694
  step("a flat src/ of components", false, "not read: a design system was found");
695
+ const named = (source) => registries.length ? `${source}; from shadcn registries, read-only: ${registries.map((r) => r.source).join(", ")}` : source;
696
+ const read = registries.map((f) => ({
697
+ source: f.source,
698
+ components: f.components,
699
+ imports: 0,
700
+ registry: f.registry
701
+ }));
621
702
  if (found.length === 1) {
622
703
  const [only] = found;
623
- return detection(root, found, withDefaults, only?.source ?? "");
704
+ const source = only?.source ?? "";
705
+ if (!only || !read.length) return detection(root, found, withDefaults, source);
706
+ return {
707
+ ...detection(root, [...found, ...registries], withDefaults, named(source)),
708
+ designSystems: [{
709
+ source,
710
+ components: only.components,
711
+ imports: 0
712
+ }, ...read]
713
+ };
624
714
  }
625
715
  const counts = importCounts(root, found.map((f) => f.prefixes ?? []));
626
716
  const ranked = found.map((f, i) => ({
@@ -631,12 +721,12 @@ function detectProject(root, tsconfig, trace, declared, packages = new PackageFi
631
721
  const [primary, ...rest] = ranked;
632
722
  const describe = (r) => `${r.f.source} (${r.imports.toLocaleString("en-US")} ${r.imports === 1 ? "import" : "imports"})`;
633
723
  return {
634
- ...detection(root, ranked.map((r) => r.f), withDefaults, `primary: ${primary ? describe(primary) : ""}; also: ${rest.map(describe).join(", ")}`),
635
- designSystems: ranked.map((r) => ({
724
+ ...detection(root, [...ranked.map((r) => r.f), ...registries], withDefaults, named(`primary: ${primary ? describe(primary) : ""}; also: ${rest.map(describe).join(", ")}`)),
725
+ designSystems: [...ranked.map((r) => ({
636
726
  source: r.f.source,
637
727
  components: r.f.components,
638
728
  imports: r.imports
639
- }))
729
+ })), ...read]
640
730
  };
641
731
  }
642
732
  let flatWhy = "no package.json";
@@ -778,7 +868,7 @@ function fromComponentsJson(root, dir, json, project, packages, note = ignore) {
778
868
  const pkgDir = name ? packages.find(name) : void 0;
779
869
  const pkg = pkgDir ? readJson$1(path.join(pkgDir, "package.json")) : void 0;
780
870
  if (!name || !pkgDir || !pkg) {
781
- note(`the ui alias ${ui} resolves through neither tsconfig paths nor a workspace package`);
871
+ note(`the ui alias ${ui} resolves through neither ${project.configFile ? path.basename(project.configFile) : "tsconfig"} paths nor a workspace package`);
782
872
  return;
783
873
  }
784
874
  imports = exportMappings(root, pkgDir, pkg);
@@ -799,6 +889,101 @@ function fromComponentsJson(root, dir, json, project, packages, note = ignore) {
799
889
  ...ownerPackage(root, uiDir, ui)
800
890
  };
801
891
  }
892
+ /** How a shadcn registry is named in components.json: `@` and a name, so none leads out of the folder. */
893
+ const REGISTRY_NAMESPACE = /^@[a-z0-9][\w-]*$/i;
894
+ /**
895
+ * The registries `components.json` names (`"registries": { "@signoff-ui": … }`), sorted, and
896
+ * where `npx shadcn add` copies their code: the components alias, through tsconfig `paths`, when
897
+ * it names a folder in the project. `why` says why there is none.
898
+ */
899
+ function registryBase(root, json, project) {
900
+ const namespaces = Object.keys(asRecord(json.registries)).filter((n) => REGISTRY_NAMESPACE.test(n)).sort();
901
+ const alias = asRecord(json.aliases).components;
902
+ if (typeof alias !== "string") return {
903
+ namespaces,
904
+ why: "no components alias"
905
+ };
906
+ const base = namespaces.length ? resolveThroughPaths(alias, project) : void 0;
907
+ if (!base || !isInside(root, base)) return {
908
+ namespaces,
909
+ why: `the components alias ${alias} names no folder in the project through tsconfig paths`
910
+ };
911
+ return {
912
+ namespaces,
913
+ base,
914
+ alias
915
+ };
916
+ }
917
+ /** The folder of a registry's code: the namespace without its `@`, under the components folder. */
918
+ function registryFolder(root, base, namespace) {
919
+ const dir = relativePath(root, path.join(base, namespace.slice(1)));
920
+ return {
921
+ namespace,
922
+ dir,
923
+ glob: `${escapePath(dir)}/**/*.{tsx,jsx}`
924
+ };
925
+ }
926
+ /**
927
+ * The folders `npx shadcn add` copies the code of the registries `components.json` names into,
928
+ * whether they hold files yet or not, outside the ui folder ({@link fromRegistries}). A config
929
+ * that lists them in `components` reads them as zero config does: read-only, after the rest.
930
+ */
931
+ function registryFolders(root, tsconfig) {
932
+ const json = readJson$1(path.join(root, "components.json"));
933
+ if (!json || !Object.keys(asRecord(json.registries)).length) return [];
934
+ const project = readProjectConfig(root, tsconfig);
935
+ const { namespaces, base } = registryBase(root, json, project);
936
+ if (!base) return [];
937
+ const ui = asRecord(json.aliases).ui;
938
+ const uiDir = typeof ui === "string" ? resolveThroughPaths(ui, project) : void 0;
939
+ return namespaces.filter((namespace) => !uiDir || !isInside(uiDir, path.join(base, namespace.slice(1)))).map((namespace) => registryFolder(root, base, namespace));
940
+ }
941
+ /**
942
+ * The folders `npx shadcn add` copies the code of the registries `components.json` names into
943
+ * (`@/components` and `@signoff-ui` → `components/signoff-ui`). Each one that holds a .tsx or
944
+ * .jsx file is a read-only design system: the app's code is checked against its components, and
945
+ * its own files are skipped as the ui folder's are. Not when a design system found is imported
946
+ * through a package's `exports` (`@workspace/ui`): a config that names that package would give
947
+ * registry code its name too. A folder inside the ui folder is already part of it. Code a
948
+ * registry copies loose into the components folder (React Bits) is not set apart.
949
+ */
950
+ function fromRegistries(root, json, ui, others, project, step) {
951
+ const { namespaces, base, alias, why } = registryBase(root, json, project);
952
+ if (!namespaces.length) return [];
953
+ const no = (reason) => {
954
+ for (const namespace of namespaces) step(`registry ${namespace}`, false, reason);
955
+ return [];
956
+ };
957
+ const exported = others.find((f) => f.imports.length);
958
+ if (exported) return no(`not read: ${exported.package ?? "a package"} is imported through its exports, and the registry's code is not`);
959
+ if (!base || alias === void 0) return no(why ?? "no components alias");
960
+ const found = [];
961
+ for (const namespace of namespaces) {
962
+ const candidate = `registry ${namespace}`;
963
+ const { dir, glob } = registryFolder(root, base, namespace);
964
+ const absolute = path.join(root, dir);
965
+ if (ui.dirs.some((d) => isInside(d, absolute))) {
966
+ step(candidate, false, `${dir} is in the ui folder`);
967
+ continue;
968
+ }
969
+ const files = isDirectory$1(absolute) ? componentGlob(root, [glob]).length : 0;
970
+ if (!files) {
971
+ step(candidate, false, `${dir} ${isDirectory$1(absolute) ? "holds no .tsx or .jsx file yet" : "does not exist yet"}`);
972
+ continue;
973
+ }
974
+ step(candidate, true, `${dir}, ${plural(files, "component file")}, read-only`);
975
+ found.push({
976
+ components: [glob],
977
+ tokens: [],
978
+ imports: [],
979
+ dirs: [],
980
+ source: `${dir} (${namespace}, ${plural(files, "file")})`,
981
+ prefixes: [`${alias}/${namespace.slice(1)}`],
982
+ registry: namespace
983
+ });
984
+ }
985
+ return found;
986
+ }
802
987
  /**
803
988
  * The workspace package a `components.json` folder belongs to, when it is not the root
804
989
  * itself (`@coss/ui/components`, through tsconfig `paths` or the package), and the
@@ -841,22 +1026,29 @@ function fromPackageComponentsJson(root, dir, pkg, packages, note = ignore) {
841
1026
  * A package whose `exports` point at component source files is a design system. An export
842
1027
  * that leads to a barrel (`./components/button` → `components/button/index.ts`, or `.` →
843
1028
  * `src/index.tsx` behind `dist/`) stands for the files the barrel re-exports, each
844
- * suggested with the export's specifier.
845
- */
846
- function fromPackage(root, dir, pkg, note = ignore) {
1029
+ * suggested with the export's specifier. An `installed` package's exports are read as an app
1030
+ * resolves them ({@link APP_CONDITIONS}), not from `dist/` back to `src/`: a widget kit
1031
+ * publishes `src/` beside `dist/` for its source maps, or points a `source` condition at it for
1032
+ * its own repository, and it is not the app's design system. Its compiled files ({@link isBuildOutput})
1033
+ * are not read at all: no barrel, component or stylesheet in them. Nor is an export that points
1034
+ * at JavaScript outside a build folder ({@link isCompiledExport}): its re-exports are not followed.
1035
+ */
1036
+ function fromPackage(root, dir, pkg, note = ignore, installed = false) {
847
1037
  const imports = [];
848
1038
  const components = [];
849
1039
  const add = (mapping, component) => {
850
1040
  imports.push(mapping);
851
1041
  if (component && !components.some((m) => m.target === mapping.target)) components.push(mapping);
852
1042
  };
853
- for (const mapping of exportMappings(root, dir, pkg)) {
1043
+ const mappings = exportMappings(root, dir, pkg, installed);
1044
+ for (const mapping of mappings) {
854
1045
  const file = path.resolve(root, mapping.target);
1046
+ if (installed && isCompiledExport(dir, file)) continue;
855
1047
  if (mapping.target.includes("*") || !SCRIPT_FILE.test(file) || !isFile(file)) {
856
1048
  add(mapping, COMPONENT_FILE.test(mapping.target));
857
1049
  continue;
858
1050
  }
859
- for (const reached of exportedFiles(file, dir)) if (COMPONENT_FILE.test(reached)) add({
1051
+ for (const reached of exportedFiles(file, dir)) if (COMPONENT_FILE.test(reached) && !(installed && isBuildOutput(dir, reached))) add({
860
1052
  specifier: mapping.specifier,
861
1053
  target: relativePath(root, reached)
862
1054
  }, true);
@@ -864,8 +1056,9 @@ function fromPackage(root, dir, pkg, note = ignore) {
864
1056
  const files = components.filter((m) => !m.target.includes("*"));
865
1057
  const patterns = components.filter((m) => m.target.includes("*"));
866
1058
  if (files.length < 3 && !patterns.length) {
867
- const built = exportMappings(root, dir, pkg).filter((m) => /(?:^|\/)(?:dist|build|lib|out|esm|cjs)\//.test(m.target));
868
- note(pkg.exports === void 0 && typeof pkg.main !== "string" ? "no exports" : built.length ? `its exports point at build output with no source found (${built.slice(0, 2).map((m) => m.target).join(", ")})` : `its exports lead to ${plural(files.length, "component file")}; a design system exports at least 3`);
1059
+ const built = mappings.filter((m) => /(?:^|\/)(?:dist|build|lib|out|esm|cjs)\//.test(m.target) || installed && isCompiledExport(dir, path.resolve(root, m.target)));
1060
+ const output = built.slice(0, 2).map((m) => m.target).join(", ");
1061
+ note(pkg.exports === void 0 && typeof pkg.main !== "string" ? "no exports" : built.length ? installed ? `its exports point at build output (${output}), not at component sources, and only sources are read` : `its exports point at build output with no source found (${output})` : `its exports lead to ${plural(files.length, "component file")}; a design system exports at least 3`);
869
1062
  return;
870
1063
  }
871
1064
  const stylesheets = imports.filter((m) => m.target.endsWith(".css") && !m.target.includes("*")).map((m) => path.resolve(root, m.target)).filter((f) => fs.existsSync(f));
@@ -983,10 +1176,11 @@ function importedSubpaths(root, names) {
983
1176
  }
984
1177
  /**
985
1178
  * `exports` subpaths with a source target, as specifiers relative to `root`. A target
986
- * built to `dist/` maps back to its source (`src/`, through the build config's entries);
987
- * without an `exports` map, `main` stands for `.`.
1179
+ * built to `dist/` maps back to its source (`src/`, through the build config's entries),
1180
+ * unless `asWritten`: then each target is the one an app that installs the package resolves
1181
+ * ({@link APP_CONDITIONS}), as it is. Without an `exports` map, `main` stands for `.`.
988
1182
  */
989
- function exportMappings(root, dir, pkg) {
1183
+ function exportMappings(root, dir, pkg, asWritten = false) {
990
1184
  const name = typeof pkg.name === "string" ? pkg.name : void 0;
991
1185
  if (!name) return [];
992
1186
  const exports = pkg.exports ?? (typeof pkg.main === "string" && /^\.?\/?(?:dist|build|lib)\//.test(pkg.main) ? pkg.main : void 0);
@@ -994,9 +1188,9 @@ function exportMappings(root, dir, pkg) {
994
1188
  let build;
995
1189
  const mappings = [];
996
1190
  for (const [key, value] of entries) {
997
- const target = exportTarget(value);
1191
+ const target = exportTarget(value, asWritten ? APP_CONDITIONS : void 0);
998
1192
  if (!target) continue;
999
- const source = target.includes("*") ? sourcePattern(dir, target) : sourceTarget(dir, key, target, build ??= buildEntries(dir));
1193
+ const source = asWritten ? void 0 : target.includes("*") ? sourcePattern(dir, target) : sourceTarget(dir, key, target, build ??= buildEntries(dir));
1000
1194
  mappings.push({
1001
1195
  specifier: key === "." ? name : `${name}/${key.replace(/^\.\//, "")}`,
1002
1196
  target: relativePath(root, path.resolve(dir, source ?? target))
@@ -1004,6 +1198,23 @@ function exportMappings(root, dir, pkg) {
1004
1198
  }
1005
1199
  return mappings;
1006
1200
  }
1201
+ /**
1202
+ * A compiled file in package `dir`'s build folder: JavaScript, JSX or CSS (`dist/index.mjs`,
1203
+ * `lib/button.jsx`, `dist/index.css`), or a pattern of them (`dist/*.jsx`). TypeScript there
1204
+ * (`lib/utils.ts`) is source.
1205
+ */
1206
+ function isBuildOutput(dir, file) {
1207
+ return /^(?:dist|build|lib|out|esm|cjs)\/.*\.(?:[cm]?jsx?|css)$/.test(toPosix(path.relative(dir, file)));
1208
+ }
1209
+ /**
1210
+ * An `exports` target of installed package `dir` that is compiled: its build output, or
1211
+ * JavaScript in any folder (`./index.js`, `./es/index.mjs`, `./build-module/index.js`), which a
1212
+ * package published from TypeScript or JSX sources ships compiled. Its re-exports are not
1213
+ * followed, so a package that ships a `.js` barrel in front of `.jsx` sources is not read.
1214
+ */
1215
+ function isCompiledExport(dir, file) {
1216
+ return isBuildOutput(dir, file) || /\.[cm]?js$/.test(file);
1217
+ }
1007
1218
  /** `./dist/*.mjs` → `src/*.tsx` (or `.ts`) when files match it there; anything else as it is. */
1008
1219
  function sourcePattern(dir, target) {
1009
1220
  const relative = target.replace(/^\.\//, "");
@@ -1019,19 +1230,40 @@ function sourcePattern(dir, target) {
1019
1230
  }
1020
1231
  return relative;
1021
1232
  }
1022
- /** The source file an export condition map points at: the first string target that is not a declaration file. */
1023
- function exportTarget(value) {
1233
+ /**
1234
+ * The `exports` conditions an app resolves an installed package through: Node.js's own
1235
+ * (`import`, `require`, `module-sync`, `node`, `default`), and the `module`, `browser` and
1236
+ * `style` that bundlers and Tailwind CSS add. Others, such as `source`, `expo-source` and
1237
+ * `development`, lead the package's own repository to its sources, and an app that installs it
1238
+ * builds the target after them: `@expo/ui`'s `"default": "./build/universal/index.js"`, not its
1239
+ * `"expo-source": "./src/universal/index.ts"` before it.
1240
+ */
1241
+ const APP_CONDITIONS = /* @__PURE__ */ new Set([
1242
+ "import",
1243
+ "require",
1244
+ "module-sync",
1245
+ "node",
1246
+ "module",
1247
+ "browser",
1248
+ "style",
1249
+ "default"
1250
+ ]);
1251
+ /**
1252
+ * The source file an export condition map points at: the first string target that is not a
1253
+ * declaration file, through any condition but `types`, or through `conditions` alone.
1254
+ */
1255
+ function exportTarget(value, conditions) {
1024
1256
  if (typeof value === "string") return value.endsWith(".d.ts") ? void 0 : value;
1025
1257
  if (Array.isArray(value)) {
1026
1258
  for (const item of value) {
1027
- const target = exportTarget(item);
1259
+ const target = exportTarget(item, conditions);
1028
1260
  if (target) return target;
1029
1261
  }
1030
1262
  return;
1031
1263
  }
1032
1264
  if (value && typeof value === "object") for (const [condition, nested] of Object.entries(value)) {
1033
- if (condition === "types") continue;
1034
- const target = exportTarget(nested);
1265
+ if (condition === "types" || conditions && !conditions.has(condition)) continue;
1266
+ const target = exportTarget(nested, conditions);
1035
1267
  if (target) return target;
1036
1268
  }
1037
1269
  }
@@ -1090,6 +1322,140 @@ function moduleResolver(root) {
1090
1322
  };
1091
1323
  }
1092
1324
  /**
1325
+ * {@link moduleResolver} for a Tailwind config's presets, then a workspace package's
1326
+ * `exports` for what it leaves unresolved: `@repo/tailwind-config` through
1327
+ * `"exports": { ".": "./tailwind.config.ts" }`, as Turborepo's template has it.
1328
+ */
1329
+ function presetResolver(root) {
1330
+ const resolve = moduleResolver(root);
1331
+ const packages = new PackageFinder(root);
1332
+ return (specifier, fromFile) => {
1333
+ const file = resolve(specifier, fromFile);
1334
+ if (file || specifier.startsWith(".")) return file;
1335
+ const name = packageName(specifier);
1336
+ const dir = name ? packages.find(name) : void 0;
1337
+ return name && dir ? exportedFile(dir, `.${specifier.slice(name.length)}`, (f) => SCRIPT_FILE.test(f)) : void 0;
1338
+ };
1339
+ }
1340
+ /**
1341
+ * Resolves a stylesheet's `@import` to a project file: a path next to the stylesheet
1342
+ * (`./theme.css`, or `theme.css` as plain CSS reads it), or a stylesheet of a workspace
1343
+ * package (`@acme/theme/colors.css`, or `@acme/theme` through its `exports` or its `style`
1344
+ * field). An installed package resolves only when zero config reads it as a design system
1345
+ * ({@link installedDesignSystem}), and a stylesheet in it imports others of that package alone.
1346
+ * In an installed package, a stylesheet in a build folder (`dist/index.css`) is compiled, and
1347
+ * resolves to nothing. Other installed packages (`tailwindcss`) and URLs resolve to nothing.
1348
+ */
1349
+ function stylesheetResolver(root) {
1350
+ const packages = new PackageFinder(root);
1351
+ const designSystems = /* @__PURE__ */ new Map();
1352
+ const designSystem = (name) => {
1353
+ if (!designSystems.has(name)) designSystems.set(name, installedDesignSystem(root, name, packages));
1354
+ return designSystems.get(name);
1355
+ };
1356
+ const stylesheet = (file, within) => [file, `${file}.css`].find((f) => f.endsWith(".css") && (within === void 0 || isInside(within, f) && !isBuildOutput(within, f)) && !/(?:^|[\\/])node_modules[\\/]/.test(within === void 0 ? f : path.relative(within, f)) && isFile(f));
1357
+ return (specifier, fromFile) => {
1358
+ if (/^(?:[a-z][\w+.-]*:|\/)/i.test(specifier)) return void 0;
1359
+ const relative = stylesheet(path.resolve(path.dirname(fromFile), specifier), installedPackageOf(fromFile));
1360
+ if (relative || specifier.startsWith(".")) return relative;
1361
+ const name = packageName(specifier);
1362
+ const workspace = name ? packages.find(name) : void 0;
1363
+ const installed = name && !workspace ? designSystem(name) : void 0;
1364
+ const dir = workspace ?? installed;
1365
+ if (!name || !dir) return void 0;
1366
+ const subpath = specifier.slice(name.length + 1);
1367
+ const key = subpath ? `./${subpath}` : ".";
1368
+ const exported = installed ? installedStylesheet(installed, key) : exportedFile(dir, key, (f) => f.endsWith(".css"));
1369
+ if (exported !== void 0) return exported ?? void 0;
1370
+ const style = readJson$1(path.join(dir, "package.json"))?.style;
1371
+ return subpath ? stylesheet(path.join(dir, subpath), installed) : stylesheet(path.resolve(dir, typeof style === "string" ? style : "index.css"), installed);
1372
+ };
1373
+ }
1374
+ /**
1375
+ * The stylesheet an app resolves `subpath` (`./globals.css`) of installed package `dir` to,
1376
+ * as the package's exported stylesheets are found ({@link exportTarget}): the most specific
1377
+ * `exports` entry that maps it decides, through its first stylesheet target under
1378
+ * {@link APP_CONDITIONS}, and a later condition is not tried. `null` when that entry leads to no
1379
+ * stylesheet, or to one in a build folder (`{ "style": "./dist/x.css", "default": "./src/x.css" }`
1380
+ * gives apps the compiled `dist/x.css`); undefined when no entry maps the subpath.
1381
+ */
1382
+ function installedStylesheet(dir, subpath) {
1383
+ const [match] = exportMatches(dir, subpath);
1384
+ if (!match) return void 0;
1385
+ const target = exportTargets(match.value, APP_CONDITIONS).map((t) => t.replaceAll("*", match.middle)).find((t) => t.endsWith(".css"));
1386
+ const file = target === void 0 ? void 0 : path.resolve(dir, target);
1387
+ return file && isInside(dir, file) && !isBuildOutput(dir, file) && isFile(file) ? file : null;
1388
+ }
1389
+ /**
1390
+ * The folder of `name` installed from a registry, when zero config reads it as a design system:
1391
+ * named like one, with `exports` that point at the component sources it publishes.
1392
+ */
1393
+ function installedDesignSystem(root, name, packages) {
1394
+ if (!isDesignSystemName(name)) return void 0;
1395
+ const dir = packages.installed(name);
1396
+ const pkg = dir ? readJson$1(path.join(dir, "package.json")) : void 0;
1397
+ const found = dir && pkg ? fromPackage(root, dir, pkg, ignore, true) : void 0;
1398
+ return found && matchesAny(root, found) ? dir : void 0;
1399
+ }
1400
+ /** The installed package a file is in: `…/node_modules/@acme/ui` for `…/node_modules/@acme/ui/src/a.css`. */
1401
+ function installedPackageOf(file) {
1402
+ const parts = path.resolve(file).split(path.sep);
1403
+ const at = parts.lastIndexOf("node_modules");
1404
+ const first = parts[at + 1] ?? ".";
1405
+ if (at === -1 || first.startsWith(".")) return void 0;
1406
+ const end = at + (first.startsWith("@") ? 3 : 2);
1407
+ return end < parts.length ? parts.slice(0, end).join(path.sep) : void 0;
1408
+ }
1409
+ /**
1410
+ * The file a package's `exports` map `subpath` (`.`, `./theme`) to, through any condition
1411
+ * (`style`, `import`, `default`) and `*` patterns: the first target on disk, inside the
1412
+ * package, that `accept` takes.
1413
+ */
1414
+ function exportedFile(dir, subpath, accept) {
1415
+ for (const { middle, value } of exportMatches(dir, subpath)) for (const target of exportTargets(value)) {
1416
+ const file = path.resolve(dir, target.replaceAll("*", middle));
1417
+ if (isInside(dir, file) && accept(file) && isFile(file)) return file;
1418
+ }
1419
+ }
1420
+ /**
1421
+ * The entries of package `dir`'s `exports` that map `subpath`, with what their `*` stands for:
1422
+ * an exact subpath first, then the most specific pattern, as Node.js picks them.
1423
+ */
1424
+ function exportMatches(dir, subpath) {
1425
+ const exports = readJson$1(path.join(dir, "package.json"))?.exports;
1426
+ const entries = exports && typeof exports === "object" && !Array.isArray(exports) ? Object.keys(exports).every((k) => k.startsWith(".")) ? Object.entries(exports) : [[".", exports]] : exports === void 0 ? [] : [[".", exports]];
1427
+ const matches = [];
1428
+ for (const [key, value] of entries) {
1429
+ const star = key.indexOf("*");
1430
+ if (star === -1) {
1431
+ if (key === subpath) matches.push({
1432
+ middle: "",
1433
+ value,
1434
+ rank: Infinity
1435
+ });
1436
+ continue;
1437
+ }
1438
+ const prefix = key.slice(0, star);
1439
+ const suffix = key.slice(star + 1);
1440
+ if (subpath.startsWith(prefix) && subpath.endsWith(suffix) && subpath.length >= prefix.length + suffix.length) {
1441
+ const middle = subpath.slice(prefix.length, subpath.length - suffix.length);
1442
+ matches.push({
1443
+ middle,
1444
+ value,
1445
+ rank: prefix.length
1446
+ });
1447
+ }
1448
+ }
1449
+ return matches.sort((a, b) => b.rank - a.rank);
1450
+ }
1451
+ /** Every target of an export condition map, in order, but its types, or those of `conditions` alone. */
1452
+ function exportTargets(value, conditions) {
1453
+ if (typeof value === "string") return [value];
1454
+ if (Array.isArray(value)) return value.flatMap((item) => exportTargets(item, conditions));
1455
+ if (value && typeof value === "object") return Object.entries(value).flatMap(([condition, nested]) => condition === "types" || conditions && !conditions.has(condition) ? [] : exportTargets(nested, conditions));
1456
+ return [];
1457
+ }
1458
+ /**
1093
1459
  * Where an import from a workspace package points: its file, or `{}` for a
1094
1460
  * subpath the package's `exports` declare but whose target is not on disk
1095
1461
  * (built to `dist/`). Undefined when no such module exists. The linter tells
@@ -1261,14 +1627,8 @@ var PackageFinder = class {
1261
1627
  * checkout that is not installed (only then is the workspace scanned). With its package.json.
1262
1628
  */
1263
1629
  locate(name) {
1264
- for (const nodeModules of this.nodeModulesDirs()) {
1265
- const dir = path.join(nodeModules, ...name.split("/"));
1266
- const json = cachedManifest(path.join(dir, "package.json"));
1267
- if (json) return {
1268
- dir,
1269
- json
1270
- };
1271
- }
1630
+ const found = this.inNodeModules(name);
1631
+ if (found) return found;
1272
1632
  this.linkable ??= scanWorkspace(this.root, false);
1273
1633
  const dir = this.linkable.get(name);
1274
1634
  const json = dir ? cachedManifest(path.join(dir, "package.json")) : void 0;
@@ -1293,6 +1653,30 @@ var PackageFinder = class {
1293
1653
  boundary: installed ? dir : this.boundaryOf(dir)
1294
1654
  };
1295
1655
  }
1656
+ /**
1657
+ * Where `name` is installed from a registry, rather than linked to a workspace package: its
1658
+ * real folder, where pnpm puts its own dependencies beside it, expressed from the root. When
1659
+ * that is outside the root and its workspace (pnpm's store elsewhere on the machine), the
1660
+ * folder in `node_modules` it was found by, the only way the loader reads it.
1661
+ */
1662
+ installed(name) {
1663
+ const located = this.inNodeModules(name);
1664
+ if (!located) return void 0;
1665
+ const placed = this.place(located.dir);
1666
+ if (!placed.installed) return void 0;
1667
+ return isInside(this.root, placed.dir) || this.boundaryOf(placed.dir) !== placed.dir ? placed.dir : path.resolve(located.dir);
1668
+ }
1669
+ /** `name` in the nearest `node_modules` at or above the root that has it, with its package.json. */
1670
+ inNodeModules(name) {
1671
+ for (const nodeModules of this.nodeModulesDirs()) {
1672
+ const dir = path.join(nodeModules, ...name.split("/"));
1673
+ const json = cachedManifest(path.join(dir, "package.json"));
1674
+ if (json) return {
1675
+ dir,
1676
+ json
1677
+ };
1678
+ }
1679
+ }
1296
1680
  /** The `node_modules` folders at and above the root, nearest first, looked up once. */
1297
1681
  nodeModulesDirs() {
1298
1682
  if (!this.nodeModules) {
@@ -1305,24 +1689,47 @@ var PackageFinder = class {
1305
1689
  }
1306
1690
  return this.nodeModules;
1307
1691
  }
1308
- /** The workspace root when `dir` is in it, else `dir` itself. */
1692
+ /**
1693
+ * The workspace root when `dir` is in it, else `dir` itself. Never the home folder or above:
1694
+ * a stray `~/package.json` with `workspaces` would let a package's config name
1695
+ * `../../../.aws/credentials`.
1696
+ */
1309
1697
  boundaryOf(dir) {
1310
- if (this.workspace === void 0) this.workspace = workspaceRootOf(this.root) ?? null;
1698
+ if (this.workspace === void 0) {
1699
+ const workspace = workspaceRootOf(this.root);
1700
+ this.workspace = workspace !== void 0 && !isHomeOrAbove(workspace) ? workspace : null;
1701
+ }
1311
1702
  return this.workspace !== null && isInside(this.workspace, dir) ? this.workspace : dir;
1312
1703
  }
1313
1704
  };
1314
1705
  /**
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.
1706
+ * A monorepo root: a pnpm-workspace.yaml whose `packages` name more than the root,
1707
+ * `workspaces` in package.json (npm, Yarn, Bun), a lerna.json with `packages`, or an nx.json.
1708
+ * Turborepo runs on the package manager's workspaces, so its roots are among these. pnpm 11
1709
+ * keeps its settings in a pnpm-workspace.yaml in a single app too (`allowBuilds`, as
1710
+ * create-next-app --use-pnpm leaves it), and reads one without `packages`, or with only
1711
+ * `- '.'`, as the root alone: that is not a monorepo.
1318
1712
  */
1319
1713
  function isWorkspaceRoot(dir) {
1320
- if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return true;
1714
+ if (pnpmWorkspacePackages(dir).length) return true;
1321
1715
  if (fs.existsSync(path.join(dir, "nx.json"))) return true;
1322
1716
  const pkg = readJson$1(path.join(dir, "package.json"));
1323
1717
  if (pkg !== void 0 && pkg.workspaces !== void 0) return true;
1324
1718
  return Array.isArray(readJson$1(path.join(dir, "lerna.json"))?.packages);
1325
1719
  }
1720
+ /**
1721
+ * The `packages` of the pnpm-workspace.yaml in `dir` when they name a package other than the
1722
+ * root (`.`); none otherwise, and none without the file: then it holds only settings.
1723
+ */
1724
+ function pnpmWorkspacePackages(dir) {
1725
+ let list;
1726
+ try {
1727
+ list = pnpmPackages(fs.readFileSync(path.join(dir, "pnpm-workspace.yaml"), "utf8"));
1728
+ } catch {
1729
+ return [];
1730
+ }
1731
+ return list.some((p) => !p.startsWith("!") && path.posix.normalize(toPosix(p).replace(/\/+$/, "")) !== ".") ? list : [];
1732
+ }
1326
1733
  /** The nearest workspace root at or above `start`. */
1327
1734
  function workspaceRootOf(start) {
1328
1735
  for (let dir = start;; dir = path.dirname(dir)) {
@@ -1380,7 +1787,8 @@ function stampOf(files) {
1380
1787
  }
1381
1788
  /**
1382
1789
  * `packages` from pnpm-workspace.yaml, `workspaces` from package.json (npm, Yarn, Bun), or
1383
- * `packages` from lerna.json, in that order of precedence.
1790
+ * `packages` from lerna.json, in that order of precedence. A pnpm-workspace.yaml that names
1791
+ * no package beyond the root holds only settings, so the next source decides.
1384
1792
  */
1385
1793
  function workspacePatterns(dir) {
1386
1794
  const split = (list, source) => ({
@@ -1388,8 +1796,8 @@ function workspacePatterns(dir) {
1388
1796
  exclude: list.filter((p) => p.startsWith("!")).map((p) => toPosix(p.slice(1))),
1389
1797
  source
1390
1798
  });
1391
- const yaml = path.join(dir, "pnpm-workspace.yaml");
1392
- if (fs.existsSync(yaml)) return split(pnpmPackages(fs.readFileSync(yaml, "utf8")), "pnpm-workspace.yaml");
1799
+ const pnpm = pnpmWorkspacePackages(dir);
1800
+ if (pnpm.length) return split(pnpm, "pnpm-workspace.yaml");
1393
1801
  const workspaces = readJson$1(path.join(dir, "package.json"))?.workspaces;
1394
1802
  const list = Array.isArray(workspaces) ? workspaces : asRecord(workspaces).packages;
1395
1803
  if (Array.isArray(list)) return split(list.filter((p) => typeof p === "string"), "package.json workspaces");
@@ -1428,12 +1836,13 @@ function workspacePackageDirs(root, nx = true) {
1428
1836
  }
1429
1837
  /**
1430
1838
  * 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.
1839
+ * not) or flow (`[apps/*, packages/*]`) style, with comments, a quoted key or a
1840
+ * leading byte order mark. Only that key is read, so this stays a few lines
1841
+ * rather than a YAML parser.
1433
1842
  */
1434
1843
  function pnpmPackages(text) {
1435
- const lines = text.split(/\r?\n/);
1436
- const start = lines.findIndex((line) => /^packages\s*:/.test(line));
1844
+ const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
1845
+ const start = lines.findIndex((line) => /^(['"]?)packages\1\s*:/.test(line));
1437
1846
  if (start === -1) return [];
1438
1847
  const uncomment = (s) => s.replace(/(?:^|\s)#.*$/, "").trim();
1439
1848
  const unquote = (s) => s.trim().replace(/^(['"])(.*)\1$/, "$2");
@@ -1506,9 +1915,21 @@ function isBareHsl(value) {
1506
1915
  return BARE_HSL.test(value.trim());
1507
1916
  }
1508
1917
  /**
1918
+ * RGB channels without the function, `22 163 74` (commas and `/ alpha` allowed): how
1919
+ * Tailwind v3's docs store colors, to be used as `rgb(var(--success) / <alpha-value>)`.
1920
+ * Three numbers alone are no sure color, so they count as one only in a token already
1921
+ * known to be a color: one the Tailwind config reads with `rgb()`.
1922
+ */
1923
+ const BARE_RGB = /^(\d{1,3})(?:\s*,\s*|\s+)(\d{1,3})(?:\s*,\s*|\s+)(\d{1,3})(?:\s*\/\s*(\d*\.?\d+%?))?$/;
1924
+ function isBareRgb(value) {
1925
+ const match = BARE_RGB.exec(value.trim());
1926
+ return match !== null && match.slice(1, 4).every((channel) => Number(channel) <= 255);
1927
+ }
1928
+ /**
1509
1929
  * Parses any CSS color (hex, rgb(), hsl(), oklch(), named, ...) into OKLCH.
1510
1930
  * With `bareHsl`, for token values, HSL channels count too: in a stylesheet
1511
1931
  * they can only be a color, while in a class or style they are not one.
1932
+ * With `bareRgb`, for the values of color tokens, RGB channels count too.
1512
1933
  */
1513
1934
  function parseColor(value, options = {}) {
1514
1935
  const text = value.trim();
@@ -1519,10 +1940,27 @@ function parseColor(value, options = {}) {
1519
1940
  const [, h, deg = "", s, l, alpha] = hsl;
1520
1941
  parsed = parse(`hsl(${h}${deg} ${s}% ${l}%${alpha ? ` / ${alpha}` : ""})`);
1521
1942
  }
1943
+ const rgb = !parsed && options.bareRgb && isBareRgb(text) ? BARE_RGB.exec(text) : null;
1944
+ if (rgb) {
1945
+ const [, r, g, b, alpha] = rgb;
1946
+ parsed = parse(`rgb(${r} ${g} ${b}${alpha ? ` / ${alpha}` : ""})`);
1947
+ }
1522
1948
  return parsed ? toOklch$1(parsed) : void 0;
1523
1949
  }
1950
+ /** A color's opacity, from 0 (transparent) to 1 (opaque). */
1951
+ function opacity(color) {
1952
+ return color.alpha ?? 1;
1953
+ }
1954
+ /**
1955
+ * How much a difference in opacity weighs against ΔE, which leaves it out. Translucency is
1956
+ * part of what a color does (an overlay, a tint, a frosted header), so a token more than
1957
+ * 0.02 apart in opacity is never close (5 × 0.02 is CLOSE_COLOR): `bg-[#e7000b33]`, the red
1958
+ * at 20%, is not `bg-destructive`. One step of an 8-digit hex (1/255) is still the same color.
1959
+ */
1960
+ const OPACITY_WEIGHT = 5;
1961
+ /** ΔE between two colors, plus their difference in opacity. */
1524
1962
  function colorDistance(a, b) {
1525
- return okDistance(a, b);
1963
+ return okDistance(a, b) + OPACITY_WEIGHT * Math.abs(opacity(a) - opacity(b));
1526
1964
  }
1527
1965
  function isNamedColor(value) {
1528
1966
  return Object.hasOwn(colorsNamed, value.toLowerCase());
@@ -1602,6 +2040,25 @@ function sameHue(color, token, tinted = isTinted(color), tolerance = 15) {
1602
2040
  function formatDeltaE(deltaE) {
1603
2041
  return deltaE < 5e-4 ? "exact match" : `ΔE ${deltaE.toFixed(3)}`;
1604
2042
  }
2043
+ /** `opaque`, `20% opaque` or `transparent`, to the nearest percent. */
2044
+ function formatOpacity(color) {
2045
+ const percent = Math.round(opacity(color) * 100);
2046
+ return percent === 100 ? "opaque" : percent === 0 ? "transparent" : `${String(percent)}% opaque`;
2047
+ }
2048
+ /**
2049
+ * When a token's value (`token`) and a color differ in opacity, a sentence that says so:
2050
+ * `destructive is opaque, and this color is 20% opaque.`
2051
+ */
2052
+ function formatOpacityGap(color, token, name) {
2053
+ const [own, its] = [formatOpacity(color), formatOpacity(token)];
2054
+ return own === its ? void 0 : `${name} is ${its}, and this color is ${own}.`;
2055
+ }
2056
+ /** ΔE between a color and a token's value, which `formatOpacityGap` completes. */
2057
+ function formatDistance(color, token) {
2058
+ const deltaE = okDistance(color, token);
2059
+ if (deltaE < 5e-4 && formatOpacityGap(color, token, "")) return "same color";
2060
+ return formatDeltaE(deltaE);
2061
+ }
1605
2062
  //#endregion
1606
2063
  //#region src/libraries/mui.ts
1607
2064
  /** The palette groups MUI's components take as a `color` value (`<Button color="primary">`). */
@@ -1945,21 +2402,57 @@ function themeTokens(options, source) {
1945
2402
  const LIBRARY_IDS = ["mui"];
1946
2403
  /** The libraries onsystem can read, by package. */
1947
2404
  const LIBRARIES = [mui];
2405
+ /**
2406
+ * Published component libraries onsystem does not read yet, by package. A project that depends
2407
+ * on one is told so, since nothing in it is checked and a clean result would look like a
2408
+ * misconfigured one.
2409
+ */
2410
+ const UNREAD_LIBRARIES = [
2411
+ {
2412
+ package: "@chakra-ui/react",
2413
+ title: "Chakra UI"
2414
+ },
2415
+ {
2416
+ package: "@mantine/core",
2417
+ title: "Mantine"
2418
+ },
2419
+ {
2420
+ package: "@heroui/react",
2421
+ title: "HeroUI"
2422
+ },
2423
+ {
2424
+ package: "@radix-ui/themes",
2425
+ title: "Radix Themes"
2426
+ },
2427
+ {
2428
+ package: "antd",
2429
+ title: "Ant Design"
2430
+ }
2431
+ ];
1948
2432
  function libraryAdapter(name) {
1949
2433
  return LIBRARIES.find((library) => library.package === name);
1950
2434
  }
2435
+ /** A library's name for messages, read or not: `MUI`, `Chakra UI`. */
2436
+ function libraryTitle(name) {
2437
+ return [...LIBRARIES, ...UNREAD_LIBRARIES].find((l) => l.package === name)?.title ?? name;
2438
+ }
2439
+ /** Why a library in `UNREAD_LIBRARIES` is not read: `a dependency onsystem does not read yet (…)`. */
2440
+ function notReadYet() {
2441
+ return `a dependency onsystem does not read yet (it reads only ${LIBRARIES.map((l) => l.title).join(", ")} so far)`;
2442
+ }
1951
2443
  /**
1952
2444
  * The libraries `root`'s package.json depends on (dependencies, devDependencies or
1953
2445
  * peerDependencies), each found where Node.js would find it: in `node_modules` at the root or a
1954
2446
  * folder above it, as a monorepo hoists it. Nothing in the package is run. `ids` is the config's
1955
2447
  * `libraries`: undefined reads every library onsystem supports, false none, a list those named;
1956
- * the dependencies it leaves out are `off`.
2448
+ * the dependencies it leaves out are `off`. Those in `UNREAD_LIBRARIES` are `unread`.
1957
2449
  */
1958
2450
  function detectLibraries(root, ids) {
1959
2451
  const pkg = readJson(path.join(root, "package.json"));
1960
2452
  if (!pkg) return {
1961
2453
  read: [],
1962
- off: []
2454
+ off: [],
2455
+ unread: []
1963
2456
  };
1964
2457
  const dependencies = new Set([
1965
2458
  "dependencies",
@@ -1969,23 +2462,24 @@ function detectLibraries(root, ids) {
1969
2462
  const value = pkg[field];
1970
2463
  return value && typeof value === "object" ? Object.keys(value) : [];
1971
2464
  }));
1972
- const found = LIBRARIES.filter((library) => dependencies.has(library.package)).map((library) => {
1973
- const dir = installedPackage(root, library.package);
2465
+ const sourceOf = (name) => {
2466
+ const dir = installedPackage(root, name);
1974
2467
  const version = dir ? readJson(path.join(dir, "package.json"))?.version : void 0;
1975
- const source = {
1976
- package: library.package,
2468
+ return {
2469
+ package: name,
1977
2470
  dir,
1978
2471
  version: typeof version === "string" ? version : void 0
1979
2472
  };
1980
- return {
1981
- id: library.id,
1982
- source
1983
- };
1984
- });
2473
+ };
2474
+ const found = LIBRARIES.filter((library) => dependencies.has(library.package)).map((library) => ({
2475
+ id: library.id,
2476
+ source: sourceOf(library.package)
2477
+ }));
1985
2478
  const wanted = (id) => ids === void 0 || ids !== false && ids.includes(id);
1986
2479
  return {
1987
2480
  read: found.filter((f) => wanted(f.id)).map((f) => f.source),
1988
- off: found.filter((f) => !wanted(f.id)).map((f) => f.source)
2481
+ off: found.filter((f) => !wanted(f.id)).map((f) => f.source),
2482
+ unread: UNREAD_LIBRARIES.filter((library) => dependencies.has(library.package)).map((library) => sourceOf(library.package))
1989
2483
  };
1990
2484
  }
1991
2485
  /** The folder `name` is installed in for code in `root`, looking up through `node_modules` folders. */
@@ -2249,12 +2743,15 @@ function usageFor(token, tailwindNamespace) {
2249
2743
  }
2250
2744
  /**
2251
2745
  * 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.
2746
+ * (`rgb(var(--success))`) when the property holds bare HSL (RGB) channels
2747
+ * (Tailwind v3), which are no color on their own.
2254
2748
  */
2255
2749
  function cssReference(token) {
2256
2750
  if (!token.cssVar) return void 0;
2257
- return token.category === "color" && isBareHsl(token.value) ? `hsl(var(${token.cssVar}))` : `var(${token.cssVar})`;
2751
+ if (token.category !== "color") return `var(${token.cssVar})`;
2752
+ if (isBareHsl(token.value)) return `hsl(var(${token.cssVar}))`;
2753
+ if (isBareRgb(token.value)) return `rgb(var(${token.cssVar}))`;
2754
+ return `var(${token.cssVar})`;
2258
2755
  }
2259
2756
  function utilities(namespace, key) {
2260
2757
  switch (namespace) {
@@ -2301,6 +2798,7 @@ const MAX_FILES = 12;
2301
2798
  */
2302
2799
  function readTailwindColors(file, resolve) {
2303
2800
  const colors = /* @__PURE__ */ new Map();
2801
+ const rgb = /* @__PURE__ */ new Set();
2304
2802
  const files = [];
2305
2803
  const queue = [file];
2306
2804
  while (queue.length && files.length < MAX_FILES) {
@@ -2316,7 +2814,7 @@ function readTailwindColors(file, resolve) {
2316
2814
  const source = ts.createSourceFile(current, text, ts.ScriptTarget.Latest, true, scriptKind(current));
2317
2815
  const visit = (node) => {
2318
2816
  if (ts.isPropertyAssignment(node) && propertyName(node.name) === "colors" && ts.isObjectLiteralExpression(node.initializer)) {
2319
- collect(node.initializer, [], colors);
2817
+ collect(node.initializer, [], colors, rgb);
2320
2818
  return;
2321
2819
  }
2322
2820
  const specifier = importedSpecifier(node);
@@ -2328,11 +2826,12 @@ function readTailwindColors(file, resolve) {
2328
2826
  }
2329
2827
  return {
2330
2828
  colors,
2829
+ rgb,
2331
2830
  files
2332
2831
  };
2333
2832
  }
2334
2833
  /** `primary: { DEFAULT: "hsl(var(--primary))", foreground: … }` → `--primary` → `primary`, `--primary-foreground` → `primary-foreground`. */
2335
- function collect(object, keys, colors) {
2834
+ function collect(object, keys, colors, rgb) {
2336
2835
  for (const property of object.properties) {
2337
2836
  if (!ts.isPropertyAssignment(property)) continue;
2338
2837
  const key = propertyName(property.name);
@@ -2340,13 +2839,15 @@ function collect(object, keys, colors) {
2340
2839
  const value = property.initializer;
2341
2840
  const nested = [...keys, key];
2342
2841
  if (ts.isObjectLiteralExpression(value)) {
2343
- collect(value, nested, colors);
2842
+ collect(value, nested, colors, rgb);
2344
2843
  continue;
2345
2844
  }
2346
2845
  if (!ts.isStringLiteralLike(value)) continue;
2347
2846
  const variable = /var\(\s*(--[\w-]+)/.exec(value.text)?.[1];
2348
2847
  const name = nested.filter((k) => k !== "DEFAULT").join("-");
2349
- if (variable && name && !colors.has(variable)) colors.set(variable, name);
2848
+ if (!variable || !name || colors.has(variable)) continue;
2849
+ colors.set(variable, name);
2850
+ if (/^\s*rgba?\(\s*var\(/.test(value.text)) rgb.add(variable);
2350
2851
  }
2351
2852
  }
2352
2853
  /**
@@ -2449,9 +2950,9 @@ function applyTailwindRadii(tokens, radii, configFile = "tailwind.config") {
2449
2950
  havePx.add(px);
2450
2951
  }
2451
2952
  }
2452
- /** The specifier of `require("x")`, `import x from "x"` or `import("x")`. */
2953
+ /** The specifier of `require("x")`, `import x from "x"`, `export { default } from "x"` or `import("x")`. */
2453
2954
  function importedSpecifier(node) {
2454
- if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) return node.moduleSpecifier.text;
2955
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) return node.moduleSpecifier.text;
2455
2956
  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
2957
  }
2457
2958
  function scriptKind(file) {
@@ -2500,9 +3001,13 @@ const SHADCN_COLORS = /* @__PURE__ */ new Set([
2500
3001
  * Gives color tokens their Tailwind v3 class names: the key the config maps
2501
3002
  * the custom property to, or, when the config has no colors we can read,
2502
3003
  * shadcn/ui's convention. Tokens that already have a key (`@theme`) keep it.
3004
+ * A property the config reads as RGB channels (`rgb(var(--success) / <alpha-value>)`)
3005
+ * is a color token when its values are such channels (`22 163 74`), which the
3006
+ * stylesheet alone leaves undecided.
2503
3007
  */
2504
- function applyTailwindColors(tokens, colors) {
3008
+ function applyTailwindColors(tokens, colors, rgb = /* @__PURE__ */ new Set()) {
2505
3009
  for (const token of tokens) {
3010
+ if (token.category === "other" && token.cssVar && rgb.has(token.cssVar) && [token.value, ...Object.values(token.modes ?? {})].every(isBareRgb)) token.category = "color";
2506
3011
  if (token.category !== "color" || token.tailwind !== void 0 || !token.cssVar) continue;
2507
3012
  const name = token.cssVar.slice(2);
2508
3013
  const key = colors.size ? colors.get(token.cssVar) : SHADCN_COLORS.has(name) ? name.replace(/^sidebar-background$/, "sidebar") : void 0;
@@ -2511,6 +3016,49 @@ function applyTailwindColors(tokens, colors) {
2511
3016
  token.usage = usageFor(token, "--color");
2512
3017
  }
2513
3018
  }
3019
+ /** Enough of a package's stylesheets to find its theme, without reading a whole design system. */
3020
+ const MAX_PRESET_STYLESHEETS = 200;
3021
+ /**
3022
+ * The stylesheets that set the custom properties a Tailwind v3 config maps to colors, when
3023
+ * they sit in a workspace package the config reads its presets from rather than in the
3024
+ * project: Dub's `@dub/tailwind-config`, whose `themes.css` sets `--content-error` for the
3025
+ * `content-error` its config maps. Each CSS file of such a package (outside `node_modules`
3026
+ * and build output) that declares one of them, in path order. The project's own folder, and
3027
+ * a folder that holds it (a preset at the workspace root), are not searched.
3028
+ */
3029
+ function presetStylesheets(read, root) {
3030
+ if (!read.colors.size) return [];
3031
+ const packages = /* @__PURE__ */ new Set();
3032
+ for (const file of read.files) {
3033
+ const dir = packageDir(path.dirname(file));
3034
+ if (dir && !isInside(dir, root)) packages.add(dir);
3035
+ }
3036
+ const out = [];
3037
+ for (const dir of packages) {
3038
+ const sheets = globSync("**/*.css", {
3039
+ cwd: dir,
3040
+ absolute: true,
3041
+ ignore: ["**/node_modules/**", "**/{dist,build,out,.next,.turbo,coverage}/**"]
3042
+ }).sort().slice(0, MAX_PRESET_STYLESHEETS);
3043
+ for (const sheet of sheets) {
3044
+ let text;
3045
+ try {
3046
+ text = fs.readFileSync(sheet, "utf8");
3047
+ } catch {
3048
+ continue;
3049
+ }
3050
+ if ([...text.matchAll(/(--[\w-]+)\s*:/g)].some((m) => read.colors.has(m[1] ?? ""))) out.push(path.resolve(sheet));
3051
+ }
3052
+ }
3053
+ return out;
3054
+ }
3055
+ /** The nearest folder at or above `dir` with a package.json. */
3056
+ function packageDir(dir) {
3057
+ for (let current = dir;; current = path.dirname(current)) {
3058
+ if (fs.existsSync(path.join(current, "package.json"))) return current;
3059
+ if (current === path.dirname(current)) return void 0;
3060
+ }
3061
+ }
2514
3062
  /** The project's Tailwind v3 config: `tailwind.config` in components.json, else `tailwind.config.*` in the root. */
2515
3063
  function findTailwindConfig(root) {
2516
3064
  try {
@@ -2589,7 +3137,7 @@ const librariesSchema = z.union([z.literal(false), z.array(z.enum(LIBRARY_IDS))]
2589
3137
  const configShape = {
2590
3138
  $schema: z.string().optional(),
2591
3139
  components: globsSchema.optional().describe("Globs for component source files, relative to the root."),
2592
- exclude: z.array(z.string()).optional().describe("Globs to ignore when finding components and when `check` expands folders and globs (stories, tests). Replaces the defaults; node_modules is skipped either way unless a pattern names it."),
3140
+ exclude: z.array(z.string()).optional().describe("Globs for files to leave out, relative to the root: `check` in folders and globs, the ESLint plugin, the Claude Code hook and check_ui leave them out, and finding components skips them. A folder (`app/legacy`) covers everything in it. Adds to the defaults (node_modules, .d.ts files, tests and stories); a pattern that starts with `!` takes out the default it names, such as `!**/*.d.ts`."),
2593
3141
  tokens: z.union([tokenSourceSchema, z.array(tokenSourceSchema)]).optional().describe("Token files: W3C DTCG JSON (*.json), CSS custom properties (*.css) or a DESIGN.md's front matter (*.md)."),
2594
3142
  docs: globsSchema.optional().describe("Globs for per-component Markdown/MDX docs."),
2595
3143
  tsconfig: z.string().optional().describe("tsconfig used to resolve types and path aliases."),
@@ -2709,6 +3257,7 @@ async function loadConfigWith(options, context = {}) {
2709
3257
  const excludeFromBase = (e) => {
2710
3258
  if (!inherited || e.startsWith("**/")) return e;
2711
3259
  if (declared) return fromBase(e);
3260
+ if (e === toProject) return "**";
2712
3261
  return e.startsWith(`${toProject}/`) ? e.slice(toProject.length + 1) : void 0;
2713
3262
  };
2714
3263
  const outside = [];
@@ -2756,7 +3305,14 @@ async function loadConfigWith(options, context = {}) {
2756
3305
  path: p,
2757
3306
  fallback: true
2758
3307
  }))];
2759
- const exclude = (config.exclude ?? DEFAULT_EXCLUDE).map(slashGlob).map(excludeFromBase).filter((e) => e !== void 0);
3308
+ const tidy = (e) => slashGlob(e).replace(/^(?:\.\/)+/, "").replace(/(.)\/+$/, "$1");
3309
+ const listed = config.exclude ?? [];
3310
+ const dropped = new Set(listed.filter((e) => e.startsWith("!")).map((e) => tidy(e.slice(1))));
3311
+ for (const pattern of dropped) {
3312
+ if (DEFAULT_EXCLUDE.includes(pattern)) continue;
3313
+ warnings.push(`"!${pattern}" in "exclude" takes out none of the defaults, which are ${DEFAULT_EXCLUDE.map((d) => `"${d}"`).join(", ")}: a pattern that starts with "!" names one of them.`);
3314
+ }
3315
+ const exclude = [.../* @__PURE__ */ new Set([...DEFAULT_EXCLUDE.filter((d) => !dropped.has(d)), ...listed.filter((e) => !e.startsWith("!")).map(tidy)])].map(excludeFromBase).filter((e) => e !== void 0);
2760
3316
  return {
2761
3317
  root,
2762
3318
  configFile,
@@ -2767,6 +3323,7 @@ async function loadConfigWith(options, context = {}) {
2767
3323
  ...t,
2768
3324
  path: slashGlob(t.path)
2769
3325
  })),
3326
+ ...!options.tokens?.length && configTokens === void 0 ? { tokensDetected: true } : {},
2770
3327
  docs: (options.docs?.length ? options.docs : configDocs === void 0 ? [.../* @__PURE__ */ new Set([...DEFAULT_DOCS, ...detection?.docs ?? []])] : configDocs.map(fromBase)).map(slashGlob),
2771
3328
  tsconfig,
2772
3329
  importPath: config.importPath,
@@ -2777,6 +3334,7 @@ async function loadConfigWith(options, context = {}) {
2777
3334
  libraries: libraries.read,
2778
3335
  libraryIds: config.libraries,
2779
3336
  librariesOff: libraries.off,
3337
+ librariesUnread: libraries.unread,
2780
3338
  elements: config.elements ?? {},
2781
3339
  includeDesignSystem: options.includeDesignSystem ?? (declared ? void 0 : config.includeDesignSystem) ?? false,
2782
3340
  rules,
@@ -2836,6 +3394,20 @@ function resolveRule(setting) {
2836
3394
  options: setting[1] ?? {}
2837
3395
  };
2838
3396
  }
3397
+ /** The default test and story patterns, which `--include-tests` lifts. */
3398
+ function isTestPattern(pattern) {
3399
+ return pattern === "**/*.{test,spec,stories}.{ts,tsx,js,jsx}" || pattern === "**/__{tests,stories,mocks}__/**";
3400
+ }
3401
+ /**
3402
+ * The `exclude` pattern that leaves `file` (relative to the root) out, or undefined when none
3403
+ * does. The one test `check` (in folders and globs, and for files named outright with
3404
+ * `--skip-excluded`), the ESLint plugin, the Claude Code hook and check_ui all apply, so they
3405
+ * leave out the same files. A pattern covers the files it matches and everything in a folder it
3406
+ * matches. With `includeTests`, the default test and story patterns do not count.
3407
+ */
3408
+ function excludedBy(config, file, options = {}) {
3409
+ return config.exclude.find((pattern) => !(options.includeTests && isTestPattern(pattern)) && coveredByGlob(file, pattern));
3410
+ }
2839
3411
  /**
2840
3412
  * The rules for one file: the config's `rules`, then every override whose
2841
3413
  * `files` match the path (relative to the root), in order.
@@ -3118,8 +3690,14 @@ function buildComponent$1(context, candidate) {
3118
3690
  const definitions = [...primary, ...nested];
3119
3691
  const { props, inherits, openProps } = extractProps(context, candidate, typeNode, param);
3120
3692
  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);
3693
+ const adds = (name) => !own.has(name) && props.some((p) => p.name === name);
3694
+ const variants = mergeVariants([...primary.flatMap((d) => d.variants), ...nested.flatMap((d) => d.variants).filter((v) => adds(v.name))]);
3695
+ const layouts = /* @__PURE__ */ new Map();
3696
+ for (const definition of definitions) for (const [name, layout] of variantLayouts(definition)) {
3697
+ if (!primary.includes(definition) && !adds(name)) continue;
3698
+ layouts.set(name, [...layouts.get(name) ?? [], ...layout]);
3699
+ }
3700
+ mergeVariantProps(props, variants, layouts);
3123
3701
  applyDestructuredDefaults(props, param);
3124
3702
  const position = sourceFile.getLineAndCharacterOfPosition(candidate.declaration.getStart());
3125
3703
  const info = {
@@ -3280,10 +3858,11 @@ function extractProps(context, candidate, typeNode, param) {
3280
3858
  if (checker.getIndexInfosOfType(t).length) openProps = true;
3281
3859
  for (const symbol of checker.getPropertiesOfType(t)) {
3282
3860
  const entry = own.get(symbol.getName()) ?? {
3283
- symbol,
3861
+ symbols: [],
3284
3862
  requiredIn: 0,
3285
3863
  origin: originOf(symbol)
3286
3864
  };
3865
+ if (!entry.symbols.length || type.isUnion() && !entry.symbols.includes(symbol)) entry.symbols.push(symbol);
3287
3866
  if (!(symbol.flags & ts.SymbolFlags.Optional)) entry.requiredIn++;
3288
3867
  own.set(symbol.getName(), entry);
3289
3868
  }
@@ -3292,9 +3871,9 @@ function extractProps(context, candidate, typeNode, param) {
3292
3871
  for (const [name, { origin }] of own) byOrigin.set(origin, [...byOrigin.get(origin) ?? [], name]);
3293
3872
  const summarised = new Set([...byOrigin].filter(([origin, names]) => origin === "react" || origin !== "own" && names.length > MAX_LISTED_INHERITED$1).map(([origin]) => origin));
3294
3873
  const props = [];
3295
- for (const [name, { symbol, requiredIn, origin }] of own) {
3874
+ for (const [name, { symbols, requiredIn, origin }] of own) {
3296
3875
  if (summarised.has(origin)) continue;
3297
- props.push(describeProp$1(context, name, symbol, location, requiredIn === types.length, origin));
3876
+ props.push(describeProp$1(context, name, symbols, location, requiredIn === types.length, origin));
3298
3877
  }
3299
3878
  const inherits = [];
3300
3879
  for (const origin of summarised) {
@@ -3302,7 +3881,7 @@ function extractProps(context, candidate, typeNode, param) {
3302
3881
  const key = registerPropSet(context.propSets, names);
3303
3882
  const from = origin === "react" ? inheritedLabel(context, typeNode) : origin;
3304
3883
  const deprecated = names.filter((name) => {
3305
- return (own.get(name)?.symbol)?.getJsDocTags(checker).some((tag) => tag.name === "deprecated");
3884
+ return (own.get(name)?.symbols[0])?.getJsDocTags(checker).some((tag) => tag.name === "deprecated");
3306
3885
  });
3307
3886
  inherits.push({
3308
3887
  from,
@@ -3374,21 +3953,35 @@ function originOf(symbol) {
3374
3953
  if (pkg === "@types/react" || pkg === "typescript") return "react";
3375
3954
  return pkg;
3376
3955
  }
3377
- function describeProp$1(context, name, symbol, location, required, origin) {
3956
+ /**
3957
+ * A prop that several members of a union declare takes what any of them allows: its type is
3958
+ * the union of theirs, and so are its values when each declares a set of string literals.
3959
+ */
3960
+ function describeProp$1(context, name, symbols, location, required, origin) {
3378
3961
  const { checker } = context;
3379
- const declared = checker.getTypeOfSymbolAtLocation(symbol, location);
3380
- const type = checker.getNonNullableType(declared);
3962
+ const allows = (symbol) => !(checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(symbol, location)).flags & ts.TypeFlags.Never);
3963
+ const members = symbols.some(allows) ? symbols.filter(allows) : symbols;
3964
+ const printed = /* @__PURE__ */ new Map();
3965
+ let values = [];
3966
+ for (const symbol of members) {
3967
+ const declared = checker.getTypeOfSymbolAtLocation(symbol, location);
3968
+ const type = checker.getNonNullableType(declared);
3969
+ const text = unwrapParens(checker.typeToString(declared, void 0, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope).replace(/^(?:(?:undefined|null) \| )+|(?: \| (?:undefined|null))+$/g, ""));
3970
+ if (text) printed.set(text, !type.isUnion() && (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0));
3971
+ const found = literalValues$2(type);
3972
+ values = found && values ? [...values, ...found] : void 0;
3973
+ }
3381
3974
  const prop = {
3382
3975
  name,
3383
- type: truncate(unwrapParens(checker.typeToString(declared, void 0, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope).replace(/^(?:(?:undefined|null) \| )+|(?: \| (?:undefined|null))+$/g, "")) || "unknown", 200),
3976
+ type: truncate([...printed].map(([text, fn]) => fn && printed.size > 1 ? `(${text})` : text).join(" | ") || "unknown", 200),
3384
3977
  required,
3385
3978
  kind: "prop"
3386
3979
  };
3387
- const values = literalValues$2(type);
3388
- if (values) prop.values = values;
3389
- const description = ts.displayPartsToString(symbol.getDocumentationComment(checker)).trim();
3980
+ if (values) prop.values = unique(values);
3981
+ const documented = members.find((s) => s.getDocumentationComment(checker).length) ?? members[0];
3982
+ const description = documented ? ts.displayPartsToString(documented.getDocumentationComment(checker)).trim() : "";
3390
3983
  if (description) prop.description = description;
3391
- for (const tag of symbol.getJsDocTags(checker)) {
3984
+ for (const tag of documented?.getJsDocTags(checker) ?? []) {
3392
3985
  const text = ts.displayPartsToString(tag.text).trim();
3393
3986
  if ((tag.name === "default" || tag.name === "defaultValue") && text) prop.default = text;
3394
3987
  if (tag.name === "deprecated") prop.deprecated = text || true;
@@ -3482,11 +4075,47 @@ function mergeVariants(variants) {
3482
4075
  }
3483
4076
  return [...merged.values()];
3484
4077
  }
3485
- /** Adds or enriches variant props from cva/tv definitions (works without type info). */
3486
- function mergeVariantProps(props, variants) {
4078
+ /** The layout of each variant a cva/tv definition declares, read as `parseDefinition` reads it. */
4079
+ function variantLayouts(definition) {
4080
+ const layouts = /* @__PURE__ */ new Map();
4081
+ const [first, second] = definition.node.arguments;
4082
+ const config = second ?? first;
4083
+ if (!config || !ts.isObjectLiteralExpression(config)) return layouts;
4084
+ const named = (name) => config.properties.find((p) => !ts.isSpreadAssignment(p) && propertyName(p.name) === name);
4085
+ const variants = named("variants");
4086
+ if (!variants || !ts.isPropertyAssignment(variants)) return layouts;
4087
+ if (!ts.isObjectLiteralExpression(variants.initializer)) return layouts;
4088
+ const extended = named("extend") !== void 0;
4089
+ for (const prop of variants.initializer.properties) {
4090
+ if (!ts.isPropertyAssignment(prop) || !ts.isObjectLiteralExpression(prop.initializer)) continue;
4091
+ const name = propertyName(prop.name);
4092
+ if (name === void 0) continue;
4093
+ const layout = extended ? [void 0] : [];
4094
+ for (const option of prop.initializer.properties) layout.push(ts.isPropertyAssignment(option) ? propertyName(option.name) : void 0);
4095
+ layouts.set(name, layout);
4096
+ }
4097
+ return layouts;
4098
+ }
4099
+ /** `values` in a layout's order, those it does not list where it takes them (or last). */
4100
+ function ordered(values, layout) {
4101
+ const unlisted = values.filter((v) => !layout.includes(v));
4102
+ const placed = layout.flatMap((key) => key === void 0 ? unlisted : values.includes(key) ? [key] : []);
4103
+ return unique(layout.includes(void 0) ? placed : [...placed, ...unlisted]);
4104
+ }
4105
+ /**
4106
+ * Adds or enriches variant props from cva/tv definitions (works without type info). A prop the
4107
+ * checker types as a set of strings keeps that set: the literal can take values it does not list
4108
+ * (`{ ...base, brand }`, tv's `extend`) and only gives their order. Without one, the literal's
4109
+ * keys are the values, unless it does not list them all: then the prop takes any value (or, when
4110
+ * the keys it lists are `true`/`false`, stays a boolean).
4111
+ */
4112
+ function mergeVariantProps(props, variants, layouts) {
3487
4113
  for (const variant of variants) {
3488
- const isBoolean = variant.values.every((v) => v === "true" || v === "false");
4114
+ const layout = layouts.get(variant.name) ?? variant.values;
4115
+ const open = layout.includes(void 0);
4116
+ const isBoolean = (variant.values.length > 0 || !open) && variant.values.every((v) => v === "true" || v === "false");
3489
4117
  const existing = props.find((p) => p.name === variant.name);
4118
+ const typed = existing?.values;
3490
4119
  const prop = existing ?? {
3491
4120
  name: variant.name,
3492
4121
  type: isBoolean ? "boolean" : variant.values.map((v) => JSON.stringify(v)).join(" | "),
@@ -3494,7 +4123,13 @@ function mergeVariantProps(props, variants) {
3494
4123
  kind: "variant"
3495
4124
  };
3496
4125
  prop.kind = "variant";
3497
- if (!isBoolean) {
4126
+ if (typed?.length) {
4127
+ prop.values = ordered(typed, layout);
4128
+ prop.type = prop.values.map((v) => JSON.stringify(v)).join(" | ");
4129
+ variant.values = [...prop.values];
4130
+ } else if (open && !isBoolean) {
4131
+ if (!existing) prop.type = "string";
4132
+ } else if (!isBoolean) {
3498
4133
  prop.values = [...variant.values];
3499
4134
  prop.type = variant.values.map((v) => JSON.stringify(v)).join(" | ");
3500
4135
  }
@@ -4346,6 +4981,17 @@ function walk$1(root, enter) {
4346
4981
  }
4347
4982
  }
4348
4983
  const VARIANT_FUNCTIONS = /^(cva|tv)$/;
4984
+ /** How many names deep a class value is followed: `className={tone}` with `const tone = base`. */
4985
+ const MAX_FOLLOW = 4;
4986
+ /** The node around `node`, or undefined at the file. */
4987
+ function parentScope(node) {
4988
+ return ts.isSourceFile(node) ? void 0 : node.parent;
4989
+ }
4990
+ /** Whether a declared name (`tone`, or one in `{ tone, size }`) binds `name`. */
4991
+ function binds(declared, name) {
4992
+ if (ts.isIdentifier(declared)) return declared.text === name;
4993
+ return declared.elements.some((e) => !ts.isOmittedExpression(e) && binds(e.name, name));
4994
+ }
4349
4995
  function analyze(sourceFile) {
4350
4996
  const analysis = {
4351
4997
  imports: /* @__PURE__ */ new Map(),
@@ -4394,10 +5040,100 @@ function analyze(sourceFile) {
4394
5040
  for (let i = children.length - 1; i >= 0; i--) stack.push(children[i]);
4395
5041
  }
4396
5042
  };
5043
+ /**
5044
+ * The `const` value or function a name refers to where it is used, declared in this file and
5045
+ * in scope there: undefined for a parameter, an import, a `let` or anything else that can
5046
+ * hold another value.
5047
+ */
5048
+ const declarationOf = (use) => {
5049
+ const name = use.text;
5050
+ for (let node = use.parent; node; node = parentScope(node)) {
5051
+ if (ts.isFunctionLike(node) && node.parameters.some((p) => binds(p.name, name))) return;
5052
+ if (ts.isCatchClause(node) && node.variableDeclaration) {
5053
+ if (binds(node.variableDeclaration.name, name)) return;
5054
+ }
5055
+ if ((ts.isForStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node)) && node.initializer && ts.isVariableDeclarationList(node.initializer) && node.initializer.declarations.some((d) => binds(d.name, name))) return;
5056
+ const statements = ts.isBlock(node) || ts.isSourceFile(node) || ts.isModuleBlock(node) || ts.isCaseClause(node) || ts.isDefaultClause(node) ? node.statements : void 0;
5057
+ for (const statement of statements ?? []) {
5058
+ if (ts.isFunctionDeclaration(statement) && statement.name?.text === name) return statement;
5059
+ if ((ts.isClassDeclaration(statement) || ts.isEnumDeclaration(statement)) && statement.name?.text === name) return;
5060
+ if (!ts.isVariableStatement(statement)) continue;
5061
+ const list = statement.declarationList;
5062
+ const declaration = list.declarations.find((d) => binds(d.name, name));
5063
+ if (!declaration) continue;
5064
+ return (list.flags & ts.NodeFlags.Const) !== 0 && ts.isIdentifier(declaration.name) ? declaration.initializer : void 0;
5065
+ }
5066
+ }
5067
+ };
5068
+ /** A same-file `const` value: `"bg-green-50"`, a condition, an object map. */
5069
+ const constValue = (use) => {
5070
+ const declaration = declarationOf(use);
5071
+ return declaration && !ts.isFunctionDeclaration(declaration) ? declaration : void 0;
5072
+ };
5073
+ /** The object literals a value can be: a `const` map, or a map in one (`styles.badge`). */
5074
+ const objectsOf = (expr, depth) => {
5075
+ if (depth > MAX_FOLLOW) return [];
5076
+ return valueBranches(expr).flatMap((leaf) => {
5077
+ if (ts.isObjectLiteralExpression(leaf)) return [leaf];
5078
+ if (ts.isIdentifier(leaf)) {
5079
+ const value = constValue(leaf);
5080
+ return value ? objectsOf(value, depth + 1) : [];
5081
+ }
5082
+ if (ts.isPropertyAccessExpression(leaf) || ts.isElementAccessExpression(leaf)) return entriesOf(leaf, depth).flatMap((value) => objectsOf(value, depth + 1));
5083
+ return [];
5084
+ });
5085
+ };
5086
+ /** What a key reads from a same-file map: `styles.badge`, or every value of `styles[status]`. */
5087
+ const entriesOf = (access, depth) => {
5088
+ const key = ts.isPropertyAccessExpression(access) ? access.name.text : ts.isStringLiteralLike(access.argumentExpression) || ts.isNumericLiteral(access.argumentExpression) ? access.argumentExpression.text : void 0;
5089
+ return objectsOf(access.expression, depth + 1).flatMap((object) => object.properties.flatMap((prop) => ts.isPropertyAssignment(prop) && (key === void 0 || propertyName(prop.name) === key) ? [prop.initializer] : []));
5090
+ };
5091
+ /** What a call to a function of this file returns: `toneOf(status)`. */
5092
+ const returnsOf = (call) => {
5093
+ if (!ts.isIdentifier(call.expression)) return [];
5094
+ const declaration = declarationOf(call.expression);
5095
+ if (declaration && ts.isFunctionDeclaration(declaration)) return returnedExpressions(declaration);
5096
+ const fn = declaration && asFunction(declaration);
5097
+ return fn ? returnedExpressions(fn) : [];
5098
+ };
5099
+ const followed = /* @__PURE__ */ new Set();
5100
+ const follow = (value, element, depth) => {
5101
+ if (followed.has(value)) return;
5102
+ followed.add(value);
5103
+ classValue(value, element, depth + 1);
5104
+ };
5105
+ /**
5106
+ * The classes a class value holds where it is not written out: a `const` (`className={tone}`,
5107
+ * `cn("p-4", extra)`), a key of a `const` map (`statusStyles[status]`), what a function of
5108
+ * this file returns (`toneOf(status)`), and the parts of a template or a concatenation,
5109
+ * through conditions. Strings written out in it are collected as well, once.
5110
+ */
5111
+ const classValue = (expr, element, depth) => {
5112
+ if (depth > MAX_FOLLOW) return;
5113
+ for (const leaf of valueBranches(expr)) if (ts.isStringLiteralLike(leaf) || ts.isObjectLiteralExpression(leaf)) collectStrings(leaf, element);
5114
+ else if (ts.isTemplateExpression(leaf)) {
5115
+ collectStrings(leaf, element);
5116
+ for (const span of leaf.templateSpans) classValue(span.expression, element, depth);
5117
+ } else if (ts.isBinaryExpression(leaf) && leaf.operatorToken.kind === ts.SyntaxKind.PlusToken) {
5118
+ classValue(leaf.left, element, depth);
5119
+ classValue(leaf.right, element, depth);
5120
+ } else if (ts.isArrayLiteralExpression(leaf)) for (const item of leaf.elements) classValue(ts.isSpreadElement(item) ? item.expression : item, element, depth);
5121
+ else if (ts.isIdentifier(leaf)) {
5122
+ const value = constValue(leaf);
5123
+ if (value) follow(value, element, depth);
5124
+ } else if (ts.isPropertyAccessExpression(leaf) || ts.isElementAccessExpression(leaf)) for (const value of entriesOf(leaf, depth)) follow(value, element, depth);
5125
+ else if (ts.isCallExpression(leaf)) {
5126
+ const callee = leaf.expression.getText(sourceFile);
5127
+ if (CLASS_FUNCTIONS.test(callee)) {
5128
+ if (depth === 0 && !VARIANT_FUNCTIONS.test(callee)) for (const arg of leaf.arguments) classValue(arg, element, depth);
5129
+ } else for (const value of returnsOf(leaf)) follow(value, element, depth);
5130
+ }
5131
+ };
4397
5132
  /** A class value, or an object of them: tv slots, and variant options that style slots. */
4398
5133
  const collectClassValues = (node) => {
4399
5134
  if (!ts.isObjectLiteralExpression(node)) {
4400
5135
  collectStrings(node);
5136
+ classValue(node, void 0, 0);
4401
5137
  return;
4402
5138
  }
4403
5139
  for (const prop of node.properties) if (ts.isPropertyAssignment(prop)) collectClassValues(prop.initializer);
@@ -4462,7 +5198,10 @@ function analyze(sourceFile) {
4462
5198
  const name = attribute.name.getText(sourceFile);
4463
5199
  const init = attribute.initializer;
4464
5200
  if (!init) continue;
4465
- if (name === "className" || name === "class") collectStrings(init, element);
5201
+ if (name === "className" || name === "class") {
5202
+ collectStrings(init, element);
5203
+ if (ts.isJsxExpression(init) && init.expression) classValue(init.expression, element, 0);
5204
+ }
4466
5205
  if (name === "style" && ts.isJsxExpression(init) && init.expression) for (const object of styleObjects(init.expression)) analysis.styles.push({
4467
5206
  element,
4468
5207
  object
@@ -4473,7 +5212,10 @@ function analyze(sourceFile) {
4473
5212
  const variants = VARIANT_FUNCTIONS.test(node.expression.getText(sourceFile));
4474
5213
  node.arguments.forEach((arg) => {
4475
5214
  if (variants && ts.isObjectLiteralExpression(arg)) collectVariantConfig(arg);
4476
- else collectStrings(arg);
5215
+ else {
5216
+ collectStrings(arg);
5217
+ classValue(arg, void 0, 0);
5218
+ }
4477
5219
  });
4478
5220
  }
4479
5221
  return true;
@@ -4544,6 +5286,7 @@ function valueBranches(expr) {
4544
5286
  * style functions (`style={({ isPressed }) => ({ ... })}`) are read through this.
4545
5287
  */
4546
5288
  function returnedExpressions(fn) {
5289
+ if (!fn.body) return [];
4547
5290
  if (!ts.isBlock(fn.body)) return [fn.body];
4548
5291
  const out = [];
4549
5292
  const stack = [fn.body];
@@ -4583,7 +5326,7 @@ function resolveElement(element, analysis, target, file) {
4583
5326
  const [head = "", ...rest] = tag.split(".");
4584
5327
  const binding = analysis.imports.get(head);
4585
5328
  if (binding?.imported === "*" && rest.length) {
4586
- if (!target.isDesignSystemImport(binding.source, file)) return { kind: "external" };
5329
+ if (!target.checksImport(binding.source, file)) return { kind: "external" };
4587
5330
  const name = rest.join(".");
4588
5331
  const library = target.libraryOf(binding.source);
4589
5332
  if (library) return fromLibrary(target, library, name, binding.source, rest.length > 1);
@@ -4610,7 +5353,7 @@ function resolveElement(element, analysis, target, file) {
4610
5353
  if (resolved.kind !== "component") return resolved;
4611
5354
  owner = resolved.component;
4612
5355
  } else if (binding) {
4613
- if (!target.isDesignSystemImport(binding.source, file)) return { kind: "external" };
5356
+ if (!target.checksImport(binding.source, file)) return { kind: "external" };
4614
5357
  const imported = binding.imported === "default" ? target.defaultExport(binding.source, file)?.name ?? head : binding.imported;
4615
5358
  owner = target.componentFor(imported, binding.source, file);
4616
5359
  if (!owner && target.components.has(imported)) return { kind: "external" };
@@ -5007,6 +5750,11 @@ const TAILWIND_PALETTE = {
5007
5750
  950: "oklch(14.7% 0.004 49.3)"
5008
5751
  }
5009
5752
  };
5753
+ /** Tailwind's colors without shades: `white` and `black`. */
5754
+ const TAILWIND_KEYWORD_COLORS = {
5755
+ black: "#000",
5756
+ white: "#fff"
5757
+ };
5010
5758
  //#endregion
5011
5759
  //#region src/tokens/css.ts
5012
5760
  /** Tailwind v4 theme namespaces → token category. Longest prefixes first. */
@@ -5596,7 +6344,7 @@ function parseDtcgTokens(text, file, options = {}) {
5596
6344
  const type = typeOf(t);
5597
6345
  const format = (value) => {
5598
6346
  const resolved = resolve(value, /* @__PURE__ */ new Set([path]));
5599
- return t.own?.channels === "hsl" && hslChannels(resolved) || formatValue(resolved, type);
6347
+ return t.own?.channels === "hsl" && hslChannels(resolved) || t.own?.channels === "rgb" && rgbChannels(resolved) || formatValue(resolved, type);
5600
6348
  };
5601
6349
  const segments = [options.prefix, ...t.path.filter((p) => p !== "$root")].filter(Boolean);
5602
6350
  const generated = segments.length ? `--${segments.join("-").replace(/[^\w-]+/g, "-")}` : void 0;
@@ -5751,7 +6499,7 @@ function onsystemExtension(extensions) {
5751
6499
  if (typeof own.name === "string" && /^[^\s{}]+$/.test(own.name)) out.name = own.name;
5752
6500
  if (typeof own.cssVar === "string" && /^--[\w-]+$/.test(own.cssVar)) out.cssVar = own.cssVar;
5753
6501
  if (typeof own.tailwind === "string" && themeVariable(own.tailwind)) out.tailwind = own.tailwind;
5754
- if (own.channels === "hsl") out.channels = "hsl";
6502
+ if (own.channels === "hsl" || own.channels === "rgb") out.channels = own.channels;
5755
6503
  if (isObject$2(own.modes)) out.modes = own.modes;
5756
6504
  return out;
5757
6505
  }
@@ -5791,6 +6539,13 @@ function hslChannels(value) {
5791
6539
  if (h === void 0 || s === void 0 || l === void 0) return void 0;
5792
6540
  return `${h} ${s}% ${l}%${typeof value.alpha === "number" && value.alpha < 1 ? ` / ${value.alpha}` : ""}`;
5793
6541
  }
6542
+ /** `22 163 74`: an sRGB color as the bare channels a Tailwind v3 custom property holds. */
6543
+ function rgbChannels(value) {
6544
+ if (!isObject$2(value) || value.colorSpace !== "srgb" || !Array.isArray(value.components)) return;
6545
+ const [r, g, b] = value.components.map((c) => typeof c === "number" ? String(Math.round(c * 255)) : void 0);
6546
+ if (r === void 0 || g === void 0 || b === void 0) return void 0;
6547
+ return `${r} ${g} ${b}${typeof value.alpha === "number" && value.alpha < 1 ? ` / ${value.alpha}` : ""}`;
6548
+ }
5794
6549
  function formatValue(value, type) {
5795
6550
  if (typeof value === "string") return value;
5796
6551
  if (typeof value === "number" || typeof value === "boolean") return String(value);
@@ -5974,6 +6729,24 @@ function isStatusToken(token) {
5974
6729
  function tokenKey(token) {
5975
6730
  return (token.tailwind || token.name).toLowerCase().replace(/[.\s_]+/g, "-").replace(/^colou?rs?-/, "");
5976
6731
  }
6732
+ /** Name segments of a line color: `card-border`, `input`, `ring`. */
6733
+ const LINE_WORDS = [
6734
+ "border",
6735
+ "input",
6736
+ "ring",
6737
+ "outline",
6738
+ "divider",
6739
+ "separator"
6740
+ ];
6741
+ /** Name segments of a surface itself, as opposed to `muted`, which also describes text. */
6742
+ const SURFACE_NOUNS = [
6743
+ "background",
6744
+ "bg",
6745
+ "surface",
6746
+ "canvas",
6747
+ "card",
6748
+ "popover"
6749
+ ];
5977
6750
  /** Name segments that say what a token is for, checked in this order (`card-border` is a border). */
5978
6751
  const KIND_WORDS = [
5979
6752
  ["foreground", [
@@ -5981,21 +6754,9 @@ const KIND_WORDS = [
5981
6754
  "fg",
5982
6755
  "text"
5983
6756
  ]],
5984
- ["line", [
5985
- "border",
5986
- "input",
5987
- "ring",
5988
- "outline",
5989
- "divider",
5990
- "separator"
5991
- ]],
6757
+ ["line", LINE_WORDS],
5992
6758
  ["surface", [
5993
- "background",
5994
- "bg",
5995
- "surface",
5996
- "canvas",
5997
- "card",
5998
- "popover",
6759
+ ...SURFACE_NOUNS,
5999
6760
  "muted",
6000
6761
  "secondary",
6001
6762
  "accent"
@@ -6014,9 +6775,17 @@ function roleKey(token) {
6014
6775
  /** What a token is for, from its name: shadcn/ui's conventions and common DTCG names. */
6015
6776
  function kindOf(key) {
6016
6777
  const segments = key.split("-");
6778
+ if (segments[0] === "on") return "foreground";
6779
+ const namesLineOrSurface = (s) => LINE_WORDS.includes(s) || SURFACE_NOUNS.includes(s);
6780
+ if (segments[0] === "content" && !segments.some(namesLineOrSurface)) return "foreground";
6017
6781
  for (const [kind, words] of KIND_WORDS) if (segments.some((s) => words.includes(s))) return kind;
6018
6782
  return "other";
6019
6783
  }
6784
+ /** The role a token is made for, from its name (`text` for `content-muted`); undefined for `brand` or `destructive`. */
6785
+ function tokenRole(token) {
6786
+ const kind = kindOf(roleKey(token));
6787
+ return kind === "foreground" ? "text" : kind === "other" ? void 0 : kind;
6788
+ }
6020
6789
  /** Tokens made for each role, in the order to prefer them when their values tie. */
6021
6790
  const PREFERRED = {
6022
6791
  text: ["foreground", "muted-foreground"],
@@ -6137,7 +6906,7 @@ function toDtcg(tokens) {
6137
6906
  if (token.cssVar) extension.cssVar = token.cssVar;
6138
6907
  const tailwind = tailwindVariable(token);
6139
6908
  if (tailwind) extension.tailwind = tailwind;
6140
- if (own.channels) extension.channels = "hsl";
6909
+ if (own.channels) extension.channels = own.channels;
6141
6910
  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
6911
  if (Object.keys(extension).length) node.$extensions = { [ONSYSTEM_EXTENSION]: extension };
6143
6912
  insert(tree, path, node);
@@ -6259,14 +7028,17 @@ function typed(value, token, lookup) {
6259
7028
  }
6260
7029
  }
6261
7030
  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);
7031
+ if (!parseColor(value, {
7032
+ bareHsl: true,
7033
+ bareRgb: true
7034
+ })) return { value };
7035
+ const channels = isBareHsl(value) ? "hsl" : isBareRgb(value) ? "rgb" : void 0;
7036
+ const color = colorObject(channels ? `${channels}(${value.replace(/,/g, " ")})` : value);
6265
7037
  if (!color) return { value };
6266
- return bare ? {
7038
+ return channels ? {
6267
7039
  type: "color",
6268
7040
  value: color,
6269
- channels: true
7041
+ channels
6270
7042
  } : {
6271
7043
  type: "color",
6272
7044
  value: color
@@ -6705,8 +7477,11 @@ function frontMatterValue(token, key, lookup) {
6705
7477
  const value = token.value.trim();
6706
7478
  switch (key) {
6707
7479
  case "colors":
6708
- if (!parseColor(value, { bareHsl: true })) return void 0;
6709
- return isBareHsl(value) ? `hsl(${value})` : value;
7480
+ if (!parseColor(value, {
7481
+ bareHsl: true,
7482
+ bareRgb: true
7483
+ })) return void 0;
7484
+ return isBareHsl(value) ? `hsl(${value})` : isBareRgb(value) ? `rgb(${value})` : value;
6710
7485
  case "rounded":
6711
7486
  case "spacing": {
6712
7487
  if (DESIGN_DIMENSION.test(value) || value === "0") return value;
@@ -6870,6 +7645,11 @@ const ROUNDED_FULL = {
6870
7645
  line: 0
6871
7646
  }
6872
7647
  };
7648
+ /** A color token's value: bare HSL or RGB channels are its color (Tailwind v3). */
7649
+ const TOKEN_COLOR = {
7650
+ bareHsl: true,
7651
+ bareRgb: true
7652
+ };
6873
7653
  /** Query helpers over the token list: lookups and nearest-token search. */
6874
7654
  var TokenIndex = class {
6875
7655
  tokens;
@@ -6899,7 +7679,7 @@ var TokenIndex = class {
6899
7679
  const resolve = (name) => this.byVar.get(name)?.value;
6900
7680
  let unit;
6901
7681
  for (const token of tokens) if (token.category === "color") {
6902
- const color = parseColor(token.value, { bareHsl: true });
7682
+ const color = parseColor(token.value, TOKEN_COLOR);
6903
7683
  if (color) this.colors.push({
6904
7684
  token,
6905
7685
  color,
@@ -6988,20 +7768,38 @@ var TokenIndex = class {
6988
7768
  candidate: named.candidate,
6989
7769
  distance: named.distance
6990
7770
  };
6991
- if (match) return {
6992
- match: nearest,
6993
- nearest,
6994
- sameValue
6995
- };
7771
+ if (match) {
7772
+ 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];
7773
+ return {
7774
+ match: nearest,
7775
+ nearest,
7776
+ sameValue,
7777
+ closeStatus: status && {
7778
+ candidate: status.candidate,
7779
+ distance: status.distance
7780
+ }
7781
+ };
7782
+ }
6996
7783
  if (!named.hue && named.status && named.looseHue && tinted) return {
6997
7784
  nearest,
6998
7785
  reason: "status",
6999
7786
  hueOff: Math.round(hueDistance(color, valueOf(named.candidate))),
7000
7787
  sameValue
7001
7788
  };
7789
+ const other = pool.filter((s) => s.hue && !s.foreign && s.fit.tier === 2 && s.distance < .1).sort(byDistance)[0];
7790
+ const reason = !named.hue ? "hue" : named.distance >= .1 ? other ? "role" : "far" : named.foreign ? "scoped" : "role";
7791
+ if (reason === "role" && other && other !== named) return {
7792
+ nearest,
7793
+ reason,
7794
+ otherRole: {
7795
+ candidate: other.candidate,
7796
+ distance: other.distance
7797
+ },
7798
+ sameValue
7799
+ };
7002
7800
  return {
7003
7801
  nearest,
7004
- reason: !named.hue ? "hue" : named.distance >= .1 ? "far" : named.foreign ? "scoped" : "role",
7802
+ reason,
7005
7803
  sameValue
7006
7804
  };
7007
7805
  }
@@ -7057,7 +7855,7 @@ var TokenIndex = class {
7057
7855
  function colorModes(token) {
7058
7856
  const modes = {};
7059
7857
  for (const [mode, value] of Object.entries(token.modes ?? {})) {
7060
- const color = parseColor(value, { bareHsl: true });
7858
+ const color = parseColor(value, TOKEN_COLOR);
7061
7859
  if (color) modes[mode] = color;
7062
7860
  }
7063
7861
  return Object.keys(modes).length ? { modes } : {};
@@ -7551,6 +8349,16 @@ var LintTarget = class {
7551
8349
  return this.importPrefixes.some((prefix) => specifier.startsWith(prefix) || specifier === prefix.slice(0, -1));
7552
8350
  }
7553
8351
  /**
8352
+ * Whether the component rules judge the components imported from `specifier`: it points at
8353
+ * the design system, and the model has components to judge them by. A model with none (an
8354
+ * app whose design-system package publishes only compiled `dist/`) still matches the
8355
+ * configured `importPath`, but the component rules do not run there, as `check` says, so
8356
+ * what comes from it is not called invented.
8357
+ */
8358
+ checksImport(specifier, fromFile = "snippet.tsx") {
8359
+ return this.model.components.length > 0 && this.isDesignSystemImport(specifier, fromFile);
8360
+ }
8361
+ /**
7554
8362
  * Whether `name`, imported from the design system, may be a real export the
7555
8363
  * model left out rather than an invented one: its module exists outside the
7556
8364
  * folders extraction read and exports `name`, or re-exports from such a
@@ -7798,6 +8606,7 @@ const preferDesignSystemComponent = {
7798
8606
  }
7799
8607
  const component = context.target.elements.get(key);
7800
8608
  if (!component) continue;
8609
+ if (fromRegistry(component, context) && context.target.config.elements[key] === void 0) continue;
7801
8610
  if (hiddenFromUser(element)) continue;
7802
8611
  if (styledByParent(element, context)) continue;
7803
8612
  if (isBare(element)) continue;
@@ -7815,6 +8624,14 @@ const preferDesignSystemComponent = {
7815
8624
  }
7816
8625
  }
7817
8626
  };
8627
+ /**
8628
+ * Whether `component` is in the folder of a shadcn registry that components.json names
8629
+ * (`components/signoff-ui`): a read-only design system, ranked after the others.
8630
+ */
8631
+ function fromRegistry(component, context) {
8632
+ const file = component.source.file;
8633
+ return (context.target.config.designSystems ?? []).some((source) => source.registry !== void 0 && source.components.some((g) => matchesGlob(file, g)));
8634
+ }
7818
8635
  const definitionsByFile = /* @__PURE__ */ new WeakMap();
7819
8636
  /** Whether the element's `className` calls a `cva(...)` or `tv(...)` definition declared in this file. */
7820
8637
  function styledByOwnVariants(element, context) {
@@ -7969,7 +8786,10 @@ const noUnknownComponent = {
7969
8786
  }
7970
8787
  }
7971
8788
  };
7972
- /** Prop names from other libraries → the conventional name in shadcn-style systems. */
8789
+ /**
8790
+ * Prop names from other libraries → the conventional name in shadcn-style systems, which mean the
8791
+ * same thing there, so the rename is a fix.
8792
+ */
7973
8793
  const PROP_SYNONYMS = {
7974
8794
  variant: [
7975
8795
  "tone",
@@ -7999,20 +8819,20 @@ const PROP_SYNONYMS = {
7999
8819
  "show",
8000
8820
  "shown"
8001
8821
  ],
8002
- onOpenChange: [
8003
- "onClose",
8004
- "onDismiss",
8005
- "onToggle",
8006
- "onOpen",
8007
- "onVisibleChange"
8008
- ],
8009
- asChild: [
8010
- "as",
8011
- "component",
8012
- "render"
8013
- ]
8822
+ onOpenChange: ["onVisibleChange"]
8014
8823
  };
8015
8824
  /**
8825
+ * Handlers other libraries call on one side of a change, against `onOpenChange`, which runs on
8826
+ * open and on close with the new state: renamed, `onClose={() => setOpen(false)}` would keep a
8827
+ * dialog shut. The message shows the check to add instead of offering the rename.
8828
+ */
8829
+ const OPEN_CHANGE_HANDLERS = /* @__PURE__ */ new Map([
8830
+ ["onClose", "{ if (!open) … }"],
8831
+ ["onDismiss", "{ if (!open) … }"],
8832
+ ["onOpen", "{ if (open) … }"],
8833
+ ["onToggle", "…"]
8834
+ ]);
8835
+ /**
8016
8836
  * Props that mean the same thing in different conventions: native elements and Radix on one
8017
8837
  * side, React Aria Components on the other. A native field states `isInvalid` as `aria-invalid`. Whichever spelling an agent writes, the one the
8018
8838
  * component takes is suggested, in either direction. Event handlers are left out: their
@@ -8036,14 +8856,96 @@ const PROP_EQUIVALENTS = [
8036
8856
  ["defaultChecked", "defaultSelected"],
8037
8857
  ["indeterminate", "isIndeterminate"]
8038
8858
  ];
8859
+ /** Props other libraries compose with, which no prop of a similar spelling stands in for. */
8860
+ const FOREIGN_COMPOSITION = /* @__PURE__ */ new Set([
8861
+ "href",
8862
+ "as",
8863
+ "component"
8864
+ ]);
8039
8865
  /**
8040
8866
  * 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.
8867
+ * element; shadcn/ui ships both, and agents mix them up, or bring `as`, `component`
8868
+ * or `href` from other libraries. Renaming the prop is not enough (`asChild` is a
8869
+ * boolean, and a button with an `href` is still no link), so this explains the
8870
+ * other way instead of offering a fix.
8043
8871
  */
8044
8872
  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}>.`;
8873
+ const radix = `compose with asChild and a single child`;
8874
+ const radixExample = `<${tag} asChild><Link href="…">…</Link></${tag}>`;
8875
+ const base = `compose with render`;
8876
+ const baseExample = `<${tag} render={<Link href="…" />}>…</${tag}>`;
8877
+ if (name === "href") {
8878
+ if (known.has("asChild")) return {
8879
+ prop: "asChild",
8880
+ text: `For a link, Radix components ${radix}: ${radixExample}.`
8881
+ };
8882
+ if (known.has("render")) return {
8883
+ prop: "render",
8884
+ text: `For a link, Base UI components ${base}: ${baseExample}.`
8885
+ };
8886
+ return;
8887
+ }
8888
+ if ([
8889
+ "asChild",
8890
+ "as",
8891
+ "component"
8892
+ ].includes(name) && known.has("render")) return {
8893
+ prop: "render",
8894
+ text: `Base UI components ${base} instead: ${baseExample}.`
8895
+ };
8896
+ if ([
8897
+ "render",
8898
+ "as",
8899
+ "component"
8900
+ ].includes(name) && known.has("asChild")) return {
8901
+ prop: "asChild",
8902
+ text: `Radix components ${radix} instead: ${radixExample}.`
8903
+ };
8904
+ }
8905
+ /** `onClick`, `onValueChange`: a handler, whose arguments a spelling match does not tell. */
8906
+ const HANDLER = /^on[A-Z]/;
8907
+ /**
8908
+ * The prop an unknown one most likely means, and whether the rename is safe as a fix: an
8909
+ * equivalent, a synonym or the same name in other case (`onclick`, `tabindex`) means the same,
8910
+ * and so does an own prop one edit away (`varient`, `onPres`). A forwarded attribute of a
8911
+ * similar spelling is only named, since one letter can make another prop (`disable` is
8912
+ * `disabled`, but `href` is not `ref`). `ref`, own or forwarded, and forwarded handlers, whose
8913
+ * events differ (`onValueChange` is not `onVolumeChange`), are not guessed by spelling at all,
8914
+ * and neither is anything for `href`, `as` or `component` (`ref`, `is`), which compose.
8915
+ */
8916
+ function guessProp(name, own, known) {
8917
+ if (FOREIGN_COMPOSITION.has(name)) return void 0;
8918
+ const equivalents = PROP_EQUIVALENTS.find((group) => group.includes(name)) ?? [];
8919
+ const equivalent = equivalents.find((other) => other !== name && own.includes(other)) ?? equivalents.find((other) => other !== name && known.has(other));
8920
+ if (equivalent) return {
8921
+ prop: equivalent,
8922
+ fix: true
8923
+ };
8924
+ const synonym = Object.entries(PROP_SYNONYMS).find(([target, aliases]) => known.has(target) && aliases.includes(name))?.[0];
8925
+ if (synonym) return {
8926
+ prop: synonym,
8927
+ fix: true
8928
+ };
8929
+ if (OPEN_CHANGE_HANDLERS.has(name) && known.has("onOpenChange")) return {
8930
+ prop: "onOpenChange",
8931
+ fix: false
8932
+ };
8933
+ const lower = name.toLowerCase();
8934
+ const recased = [...known].find((prop) => prop.toLowerCase() === lower);
8935
+ if (recased) return {
8936
+ prop: recased,
8937
+ fix: true
8938
+ };
8939
+ const typo = closest(name, own.filter((prop) => prop !== "ref"));
8940
+ if (typo) return {
8941
+ prop: typo,
8942
+ fix: editDistance(lower, typo.toLowerCase()) <= 1
8943
+ };
8944
+ const similar = closest(name, [...known].filter((prop) => prop !== "ref" && !HANDLER.test(prop)), .25);
8945
+ return similar ? {
8946
+ prop: similar,
8947
+ fix: false
8948
+ } : void 0;
8047
8949
  }
8048
8950
  const noUnknownProp = {
8049
8951
  id: "no-unknown-prop",
@@ -8066,27 +8968,25 @@ const noUnknownProp = {
8066
8968
  context.report({
8067
8969
  start: attribute.name.getStart(context.sourceFile),
8068
8970
  end: attribute.name.end,
8069
- message: `<${element.tag}> has no prop "${name}". ${composition}`,
8070
- suggestion: name === "asChild" ? "render" : "asChild"
8971
+ message: `<${element.tag}> has no prop "${name}". ${composition.text}`,
8972
+ suggestion: composition.prop
8071
8973
  });
8072
8974
  continue;
8073
8975
  }
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;
8976
+ const guess = guessProp(name, own, known);
8977
+ const prop = guess ? component.props.find((p) => p.name === guess.prop) : void 0;
8079
8978
  const values = prop?.values?.length ? ` (${prop.values.map((v) => `"${v}"`).join(" | ")})` : "";
8979
+ const handler = guess?.prop === "onOpenChange" ? OPEN_CHANGE_HANDLERS.get(name) : void 0;
8080
8980
  const valueOk = !prop?.values || literalValues(attribute).every((l) => prop.values?.includes(l.text));
8081
- const taken = guess !== void 0 && findAttribute(element, guess) !== void 0;
8981
+ const taken = guess !== void 0 && findAttribute(element, guess.prop) !== void 0;
8082
8982
  context.report({
8083
8983
  start: attribute.name.getStart(context.sourceFile),
8084
8984
  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 ? [{
8985
+ 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}}.` : ""),
8986
+ suggestion: guess?.prop,
8987
+ fix: guess?.fix && valueOk && !taken ? [{
8088
8988
  range: [attribute.name.getStart(context.sourceFile), attribute.name.end],
8089
- text: guess
8989
+ text: guess.prop
8090
8990
  }] : void 0
8091
8991
  });
8092
8992
  }
@@ -8177,11 +9077,16 @@ const VALUE_GROUPS = [
8177
9077
  "href",
8178
9078
  "inline"
8179
9079
  ],
9080
+ [
9081
+ "xs",
9082
+ "xsmall",
9083
+ "x-small",
9084
+ "extra-small"
9085
+ ],
8180
9086
  [
8181
9087
  "sm",
8182
9088
  "small",
8183
9089
  "s",
8184
- "xs",
8185
9090
  "compact",
8186
9091
  "tiny"
8187
9092
  ],
@@ -8200,6 +9105,13 @@ const VALUE_GROUPS = [
8200
9105
  "l",
8201
9106
  "big"
8202
9107
  ],
9108
+ [
9109
+ "xl",
9110
+ "xlarge",
9111
+ "x-large",
9112
+ "extra-large",
9113
+ "huge"
9114
+ ],
8203
9115
  [
8204
9116
  "icon",
8205
9117
  "iconOnly",
@@ -8208,14 +9120,72 @@ const VALUE_GROUPS = [
8208
9120
  "icon-button"
8209
9121
  ]
8210
9122
  ];
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;
9123
+ /**
9124
+ * A value with the x's of its size step counted, so two spellings of one step compare equal:
9125
+ * `xxl` and `2xl` both give `2xl`, `icon-XXS` and `icon-2xs` both give `icon-2xs`.
9126
+ */
9127
+ function stepSpelling(value) {
9128
+ return value.toLowerCase().replace(/(^|-)(x{2,})([sl])$/, (_, start, xs, end) => `${start}${xs.length}x${end}`);
9129
+ }
9130
+ /** {@link VALUE_GROUPS} spelled as {@link stepSpelling} spells a value, to compare the two. */
9131
+ const SPELLED_GROUPS = VALUE_GROUPS.map((group) => group.map(stepSpelling));
9132
+ /**
9133
+ * An allowed value that means the same: the same step spelled another way (`xxl` → `2xl`,
9134
+ * `XL` → `xl`), or a synonym (`danger` → `destructive`, `small` → `sm`). A group is searched
9135
+ * in its own order, not the scale's: TypeScript orders a union by when the program first met
9136
+ * each literal, so another file can reorder the scale, and `positive` must stay `success` on
9137
+ * a scale that also has `active`.
9138
+ */
9139
+ function synonymValue(value, allowed) {
9140
+ const spelling = stepSpelling(value);
9141
+ const same = allowed.find((member) => member !== value && stepSpelling(member) === spelling);
9142
+ if (same) return same;
9143
+ for (const group of SPELLED_GROUPS) {
9144
+ if (!group.includes(spelling)) continue;
9145
+ for (const member of group) {
9146
+ if (member === spelling) continue;
9147
+ const match = allowed.find((candidate) => stepSpelling(candidate) === member);
9148
+ if (match) return match;
9149
+ }
8217
9150
  }
8218
- return closest(value, allowed, .5);
9151
+ }
9152
+ /**
9153
+ * The step a size one past an end of the scale stands next to, with its x's counted and its
9154
+ * prefix kept: `2xl` → `xl`, `3xl` → `2xl`, `2xs` → `xs`, `xs` → `sm`, `icon-2xl` → `icon-xl`.
9155
+ */
9156
+ function stepInside(spelling) {
9157
+ const match = /^(.*-)?(\d*)x([sl])$/.exec(spelling);
9158
+ if (!match) return void 0;
9159
+ const prefix = match[1] ?? "";
9160
+ const end = match[3];
9161
+ if (!match[2]) return end === "s" ? `${prefix}sm` : void 0;
9162
+ const count = Number(match[2]);
9163
+ if (count < 2) return void 0;
9164
+ return `${prefix}${count === 2 ? "" : count - 1}x${end}`;
9165
+ }
9166
+ /**
9167
+ * The end of the scale a size one step past it stands next to, under whichever name the scale
9168
+ * gives it: `2xl` or `xxl` → `xl`, `3xl` → `2xl` or `xxl`, `xs` → `sm` or `small`, `icon-xxl`
9169
+ * → `icon-xl`. It renders another size than the one written, so it is named in the message
9170
+ * but not fixed.
9171
+ */
9172
+ function nextStep(value, allowed) {
9173
+ const step = stepInside(stepSpelling(value));
9174
+ if (!step) return void 0;
9175
+ return allowed.find((member) => stepSpelling(member) === step) ?? synonymValue(step, allowed);
9176
+ }
9177
+ /** The step of a size scale a value names, alone or at its end: `xs`, `lg`, `2xl`, `icon-sm`. */
9178
+ const SIZE_STEP = /(?:^|-)(\d*x*[sml]|sm|md|lg)$/i;
9179
+ /**
9180
+ * An allowed value spelled close to `value`, for the message only: it may be another value
9181
+ * altogether. Two steps of a size scale are never guessed from each other, since one letter
9182
+ * there is another size (`xl` and `xs`, `icon-xl` and `icon-xs`).
9183
+ */
9184
+ function spelledLike(value, allowed) {
9185
+ const guess = closest(value, allowed, .5);
9186
+ const step = SIZE_STEP.exec(value)?.[1]?.toLowerCase();
9187
+ const guessStep = guess ? SIZE_STEP.exec(guess)?.[1]?.toLowerCase() : void 0;
9188
+ return step && guessStep && step !== guessStep ? void 0 : guess;
8219
9189
  }
8220
9190
  const noUnknownVariant = {
8221
9191
  id: "no-unknown-variant",
@@ -8230,7 +9200,8 @@ const noUnknownVariant = {
8230
9200
  if (!prop?.values) continue;
8231
9201
  for (const literal of literalValues(attribute)) {
8232
9202
  if (prop.values.includes(literal.text)) continue;
8233
- const guess = suggestValue(literal.text, prop.values);
9203
+ const synonym = synonymValue(literal.text, prop.values);
9204
+ const guess = synonym ?? nextStep(literal.text, prop.values) ?? spelledLike(literal.text, prop.values);
8234
9205
  const start = literal.getStart(context.sourceFile) + 1;
8235
9206
  const end = literal.end - 1;
8236
9207
  context.report({
@@ -8238,9 +9209,9 @@ const noUnknownVariant = {
8238
9209
  end: end + 1,
8239
9210
  message: `"${literal.text}" is not a valid ${name} for <${element.tag}>. Allowed: ${prop.values.join(", ")}.` + (guess ? ` Did you mean "${guess}"?` : ""),
8240
9211
  suggestion: guess ? `${name}="${guess}"` : void 0,
8241
- fix: guess ? [{
9212
+ fix: synonym ? [{
8242
9213
  range: [start, end],
8243
- text: guess
9214
+ text: synonym
8244
9215
  }] : void 0
8245
9216
  });
8246
9217
  }
@@ -8248,27 +9219,205 @@ const noUnknownVariant = {
8248
9219
  }
8249
9220
  }
8250
9221
  };
9222
+ /**
9223
+ * Labels guessed from an icon's name, for the message. The third pattern names the icons that mean
9224
+ * one thing on a button (a trash can deletes, a magnifier searches), matched against the name
9225
+ * without its digits or `Icon` suffix (`Trash2Icon` is `Trash`): only their label is fixed. A plus
9226
+ * may add or expand, a chevron page or expand, an X close, remove or clear, so those are fixed only
9227
+ * where the code says which (`iconLabel`), and the rest are named without a fix.
9228
+ */
8251
9229
  const ICON_LABELS = [
8252
- [/^(Trash|Delete|Bin)/, "Delete"],
9230
+ [
9231
+ /^(Trash|Delete|Bin)/,
9232
+ "Delete",
9233
+ /^(?:Trash|Delete|Bin)$/
9234
+ ],
8253
9235
  [/^(X|Close|Cross)(Icon)?$|^X(Circle|Square)/, "Close"],
8254
9236
  [/^(Plus|Add)/, "Add"],
8255
- [/^(Pencil|Edit|PenLine|SquarePen)/, "Edit"],
8256
- [/^Search/, "Search"],
9237
+ [
9238
+ /^(Pencil|Edit|PenLine|SquarePen)/,
9239
+ "Edit",
9240
+ /^(?:Pencil|Edit|SquarePen)$/
9241
+ ],
9242
+ [
9243
+ /^Search/,
9244
+ "Search",
9245
+ /^Search$/
9246
+ ],
8257
9247
  [/^(Menu|Hamburger)/, "Open menu"],
8258
- [/^(Settings|Cog|Gear)/, "Settings"],
8259
- [/^(Copy|Clipboard)/, "Copy"],
8260
- [/^Download/, "Download"],
8261
- [/^Upload/, "Upload"],
9248
+ [
9249
+ /^(Settings|Cog|Gear)/,
9250
+ "Settings",
9251
+ /^(?:Settings|Cog|Gear)$/
9252
+ ],
9253
+ [
9254
+ /^(Copy|Clipboard)/,
9255
+ "Copy",
9256
+ /^(?:Copy|ClipboardCopy)$/
9257
+ ],
9258
+ [
9259
+ /^Download/,
9260
+ "Download",
9261
+ /^Download$/
9262
+ ],
9263
+ [
9264
+ /^Upload/,
9265
+ "Upload",
9266
+ /^Upload$/
9267
+ ],
8262
9268
  [/^(MoreHorizontal|MoreVertical|Ellipsis|DotsHorizontal|DotsVertical)/, "More options"],
8263
9269
  [/^ChevronLeft|^ArrowLeft/, "Previous"],
8264
9270
  [/^ChevronRight|^ArrowRight/, "Next"],
8265
9271
  [/^(UserMinus|UserX)/, "Remove member"],
8266
9272
  [/^(UserPlus)/, "Invite member"],
8267
9273
  [/^(RefreshCw|RefreshCcw|RotateCw)/, "Refresh"],
8268
- [/^(Share)/, "Share"],
8269
- [/^(Filter)/, "Filter"],
9274
+ [
9275
+ /^(Share)/,
9276
+ "Share",
9277
+ /^Share$/
9278
+ ],
9279
+ [
9280
+ /^(Filter)/,
9281
+ "Filter",
9282
+ /^Filter$/
9283
+ ],
8270
9284
  [/^(Info)/, "More information"]
8271
9285
  ];
9286
+ /** A part that expands and collapses: `CollapsibleTrigger`, `Accordion.Trigger`, `DisclosureButton`. */
9287
+ const TOGGLE_PART = /(?:Collapsible|Accordion|Disclosure)\w*\.?\w*(?:Trigger|Button)$/;
9288
+ /** A part that closes its dialog, sheet or popover: `DialogClose`, `Dialog.Close`. */
9289
+ const CLOSE_PART = /Close$/;
9290
+ /** A part that opens a menu: `DropdownMenuTrigger`, `Menu.Trigger`, Headless UI's `MenuButton`. */
9291
+ const MENU_TRIGGER = /Menu\w*\.?\w*Trigger$|^(?:\w+\.)?Menu\.?Button$/;
9292
+ /** Classes styled by an expanded state: `data-[state=open]:rotate-90`, `aria-expanded:bg-muted`. */
9293
+ const EXPANDED_CLASS = /data-\[state=(?:open|closed)\]|aria-expanded:/;
9294
+ /** The tags of the button and of every element around it, its `render` host included. */
9295
+ function enclosingTags(context, element) {
9296
+ const tags = [element.tag];
9297
+ ts.findAncestor(element.node.parent, (node) => {
9298
+ if (ts.isJsxElement(node)) tags.push(node.openingElement.tagName.getText(context.sourceFile));
9299
+ return false;
9300
+ });
9301
+ return tags;
9302
+ }
9303
+ /** The names a callee is reached by: `deleteMutation.mutate` is `deleteMutation` and `mutate`. */
9304
+ function calleeNames(expression) {
9305
+ const names = [];
9306
+ let current = expression;
9307
+ for (;;) if (ts.isNonNullExpression(current) || ts.isParenthesizedExpression(current)) current = current.expression;
9308
+ else if (ts.isPropertyAccessExpression(current)) {
9309
+ names.unshift(current.name.text);
9310
+ current = current.expression;
9311
+ } else {
9312
+ if (ts.isIdentifier(current)) names.unshift(current.text);
9313
+ return names;
9314
+ }
9315
+ }
9316
+ /** The words that name each action in a function's name. */
9317
+ const ACTION_WORDS = [
9318
+ ["Remove", ["remove", "delete"]],
9319
+ ["Clear", ["clear", "reset"]],
9320
+ ["Close", [
9321
+ "close",
9322
+ "dismiss",
9323
+ "hide"
9324
+ ]]
9325
+ ];
9326
+ /** Calls that tidy up around the action rather than name it: `clearTimeout(timer)`. */
9327
+ const HOUSEKEEPING = /^(?:clearTimeout|clearInterval|clearImmediate|removeEventListener|removeListener)$/;
9328
+ /**
9329
+ * What one call or handler reference does, by its name: `removeAssertion`, `onClose`,
9330
+ * `setOpen(false)`. A name with words of two actions (`closeDeleteDialog`, `hideRemoveConfirm`) or
9331
+ * with `cancel` (`onCancelDelete`) says none.
9332
+ */
9333
+ function actionOf(names, argument) {
9334
+ const last = names.at(-1) ?? "";
9335
+ if (HOUSEKEEPING.test(last)) return void 0;
9336
+ const words = names.flatMap((name) => name.split(/(?=[A-Z])|_/)).map((w) => w.toLowerCase());
9337
+ if (argument?.kind === ts.SyntaxKind.FalseKeyword && words.some((w) => w === "open" || w === "show" || w === "visible")) return "Close";
9338
+ if (/^set[A-Z]/.test(last) || words.includes("cancel")) return void 0;
9339
+ const actions = ACTION_WORDS.filter(([, named]) => words.some((w) => named.includes(w)));
9340
+ return actions.length === 1 ? actions[0]?.[0] : void 0;
9341
+ }
9342
+ /**
9343
+ * What a button's `onClick` (or React Aria's `onPress`) does, by the names of the functions it
9344
+ * calls or passes, and whether that is sure: only when every name says the same action. A close
9345
+ * button that also tidies up (`form.reset(); setOpen(false)`) or a button that removes a row and
9346
+ * closes a popover is named "Close", or "Remove" when it removes and clears, without a fix.
9347
+ * Callbacks inside the handler run later, on their own events (`mutate(id, { onSuccess: () =>
9348
+ * toast.dismiss() })`), so they do not count.
9349
+ */
9350
+ function handlerAction(attributes) {
9351
+ const found = /* @__PURE__ */ new Set();
9352
+ for (const attribute of attributes) {
9353
+ if (!/^on(?:Click|Press)$/.test(attributeName(attribute))) continue;
9354
+ const init = attribute.initializer;
9355
+ const value = init && ts.isJsxExpression(init) ? init.expression : void 0;
9356
+ if (!value) continue;
9357
+ const reference = actionOf(calleeNames(value));
9358
+ if (reference) found.add(reference);
9359
+ const stack = [value];
9360
+ for (let node = stack.pop(); node; node = stack.pop()) {
9361
+ if (ts.isCallExpression(node)) {
9362
+ const action = actionOf(calleeNames(node.expression), node.arguments[0]);
9363
+ if (action) found.add(action);
9364
+ }
9365
+ ts.forEachChild(node, (child) => {
9366
+ if (!ts.isFunctionLike(child)) stack.push(child);
9367
+ });
9368
+ }
9369
+ }
9370
+ const [only] = found;
9371
+ if (!only) return void 0;
9372
+ if (found.size === 1) return {
9373
+ action: only,
9374
+ sure: true
9375
+ };
9376
+ return {
9377
+ action: found.has("Close") ? "Close" : "Remove",
9378
+ sure: false
9379
+ };
9380
+ }
9381
+ /**
9382
+ * The label for an icon-only button, and whether the fix may write it: only when the icon means
9383
+ * one thing on a button, or the code says what it does. A chevron that expands or collapses gets
9384
+ * no direction; an X is named by its handler (`Remove`, `Clear`, `Close`), and `Close` is fixed
9385
+ * in a close part; "More options" is fixed on a menu's trigger.
9386
+ */
9387
+ function iconLabel(context, element, host, icon) {
9388
+ const entry = icon ? ICON_LABELS.find(([pattern]) => pattern.test(icon)) : void 0;
9389
+ if (!icon || !entry) return { fixed: false };
9390
+ const [, label, plain] = entry;
9391
+ const tags = enclosingTags(context, element);
9392
+ const owners = host ? [element, host] : [element];
9393
+ const attributes = owners.flatMap((owner) => owner.attributes);
9394
+ if (label === "Next" || label === "Previous") return {
9395
+ 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,
9396
+ fixed: false
9397
+ };
9398
+ if (label === "Close") {
9399
+ if (tags.some((tag) => CLOSE_PART.test(tag))) return {
9400
+ label,
9401
+ fixed: true
9402
+ };
9403
+ const handler = handlerAction(attributes);
9404
+ return handler ? {
9405
+ label: handler.action,
9406
+ fixed: handler.sure
9407
+ } : {
9408
+ label,
9409
+ fixed: false
9410
+ };
9411
+ }
9412
+ if (label === "More options") return {
9413
+ label,
9414
+ fixed: tags.some((tag) => MENU_TRIGGER.test(tag))
9415
+ };
9416
+ return {
9417
+ label,
9418
+ fixed: plain?.test(icon.replace(/Icon$/, "").replace(/\d+$/, "")) ?? false
9419
+ };
9420
+ }
8272
9421
  /** Text wins, then content the rule cannot read, then icons. */
8273
9422
  const PRECEDENCE = [
8274
9423
  "text",
@@ -8456,14 +9605,58 @@ function isHidden(element) {
8456
9605
  return opening !== void 0 && hiddenBy(opening.attributes);
8457
9606
  }) !== void 0;
8458
9607
  }
9608
+ /**
9609
+ * A link a design system renders through `asChild`: `<a>`, a component that renders one, or a
9610
+ * router's `Link` (`next/link`, `NavLink`) with its `href` or `to`, not an icon named `ExternalLink`.
9611
+ */
9612
+ function isLink(context, element) {
9613
+ const resolution = context.resolve(element);
9614
+ if (resolution.kind === "intrinsic") return resolution.tag === "a";
9615
+ if (resolution.kind === "component" && resolution.component.element === "a") return true;
9616
+ return /Link$/.test(element.tag) && Boolean(findAttribute(element, "href") ?? findAttribute(element, "to"));
9617
+ }
9618
+ /**
9619
+ * `<Button asChild size="icon"><Link href="/users/1/edit"><Pencil /></Link></Button>`: Radix's
9620
+ * Slot renders the link with the button's props, so the link is the button, and an icon alone
9621
+ * in it names nothing. A label on either counts (`aria-label`, `title`, visually hidden text in
9622
+ * the link). A child that is a button itself is checked on its own; any other child (a `<span>`
9623
+ * under a tooltip's trigger) is not a control.
9624
+ */
9625
+ function checkAsChildLink(context, button) {
9626
+ const children = button.children.filter((c) => !ts.isJsxText(c) || c.text.trim());
9627
+ const [child] = children;
9628
+ if (children.length !== 1 || !child) return;
9629
+ const link = context.analysis.elements.find((e) => e.node === child);
9630
+ if (!link || link.hasSpread || !isLink(context, link)) return;
9631
+ if (hasLabel(button.attributes) || hasLabel(link.attributes)) return;
9632
+ if (labelledByParent(context, button)) return;
9633
+ const icons = [];
9634
+ const content = classify(link.children, context, icons);
9635
+ const sizeIcon = attributeLiterals(button, "size").some((v) => v.text.includes("icon"));
9636
+ if (content !== "icon" && !(content === "empty" && sizeIcon)) return;
9637
+ const { label, fixed } = iconLabel(context, link, button, icons[0]);
9638
+ const attribute = `aria-label="${label ?? "…"}"`;
9639
+ context.report({
9640
+ ...tagRange(context, link),
9641
+ message: content === "icon" ? `Icon-only <${link.tag}> in <${button.tag} asChild> has no accessible name. Add ${attribute} describing the action, or visually hidden text.` : `<${link.tag}> in <${button.tag} asChild> has no content and no accessible name. Add ${attribute}.`,
9642
+ suggestion: attribute,
9643
+ fix: fixed ? [{
9644
+ range: [link.tagName.end, link.tagName.end],
9645
+ text: ` ${attribute}`
9646
+ }] : void 0
9647
+ });
9648
+ }
8459
9649
  const iconButtonAccessibleName = {
8460
9650
  id: "icon-button-accessible-name",
8461
9651
  description: "Buttons whose only content is an icon need an aria-label (or visually hidden text).",
8462
9652
  run(context) {
8463
9653
  for (const element of context.analysis.elements) {
8464
9654
  const resolution = context.resolve(element);
8465
- if (!isButton(context, element) || element.hasSpread) continue;
8466
- if (findAttribute(element, "asChild") || isHidden(element)) continue;
9655
+ if (!isButton(context, element) || element.hasSpread || isHidden(element)) continue;
9656
+ if (findAttribute(element, "asChild")) {
9657
+ checkAsChildLink(context, element);
9658
+ continue;
9659
+ }
8467
9660
  const host = renderHost(context, element);
8468
9661
  if (host === "object") continue;
8469
9662
  if (host && (isButton(context, host) || host.hasSpread)) continue;
@@ -8474,14 +9667,13 @@ const iconButtonAccessibleName = {
8474
9667
  const content = host && !children.length ? "unknown" : classify(children, context, icons);
8475
9668
  const sizeIcon = attributeLiterals(element, "size").some((v) => v.text.includes("icon"));
8476
9669
  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;
9670
+ const { label, fixed } = iconLabel(context, element, host, icons[0]);
8479
9671
  const attribute = `aria-label="${label ?? "…"}"`;
8480
9672
  context.report({
8481
9673
  ...tagRange(context, element),
8482
9674
  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
9675
  suggestion: attribute,
8484
- fix: label ? [{
9676
+ fix: fixed ? [{
8485
9677
  range: [element.tagName.end, element.tagName.end],
8486
9678
  text: ` ${attribute}`
8487
9679
  }] : void 0
@@ -8491,6 +9683,60 @@ const iconButtonAccessibleName = {
8491
9683
  };
8492
9684
  //#endregion
8493
9685
  //#region src/lint/rules/hardcoded.ts
9686
+ /** `var(--color-green-50)`: a shade of Tailwind's palette through its CSS variable. */
9687
+ const PALETTE_VARIABLE = /^var\(\s*--color-([a-z]+-(?:50|[1-9]00|950))\s*\)$/;
9688
+ /** Luminance values, not design colors: `white`/`black` (and hex twins). */
9689
+ const MASK_LUMINANCE = /* @__PURE__ */ new Set([
9690
+ "white",
9691
+ "black",
9692
+ "#fff",
9693
+ "#ffffff",
9694
+ "#000",
9695
+ "#000000"
9696
+ ]);
9697
+ /**
9698
+ * Whether the element sits inside an SVG `<mask>`: a stop's luminance sets
9699
+ * opacity there, so `white`/`black` stops are not design-system colors (#59).
9700
+ */
9701
+ function isInsideMask(element) {
9702
+ const sourceFile = element.node.getSourceFile();
9703
+ return ts.findAncestor(element.node, (node) => {
9704
+ if (!ts.isJsxElement(node) && !ts.isJsxSelfClosingElement(node)) return false;
9705
+ return (ts.isJsxElement(node) ? node.openingElement : node).tagName.getText(sourceFile) === "mask";
9706
+ }) !== void 0;
9707
+ }
9708
+ /** `id` of the wrapping gradient, if the stop sits directly in one. */
9709
+ function gradientIdOfStop(element) {
9710
+ const gradient = ts.findAncestor(element.node.parent, (node) => ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node));
9711
+ if (gradient === void 0) return void 0;
9712
+ const opening = ts.isJsxElement(gradient) ? gradient.openingElement : gradient;
9713
+ const tag = opening.tagName.getText(element.node.getSourceFile());
9714
+ if (tag !== "linearGradient" && tag !== "radialGradient") return;
9715
+ const idAttr = opening.attributes.properties.filter(ts.isJsxAttribute).find((a) => attributeName(a) === "id");
9716
+ return idAttr?.initializer && ts.isStringLiteralLike(idAttr.initializer) ? idAttr.initializer.text : void 0;
9717
+ }
9718
+ const URL_REF = /url\(\s*#([^)\s]+)\s*\)/g;
9719
+ /**
9720
+ * Gradient ids referenced via `fill="url(#id)"` (or `stroke`) from inside a
9721
+ * `<mask>` subtree: stops of those gradients paint mask luminance, not
9722
+ * colors (#59, cal.com gridFade/gridMask shape).
9723
+ */
9724
+ function maskReferencedGradientIds(elements) {
9725
+ const ids = /* @__PURE__ */ new Set();
9726
+ const refsFrom = (node) => {
9727
+ if (ts.isJsxAttribute(node)) {
9728
+ const name = attributeName(node);
9729
+ if ((name === "fill" || name === "stroke") && node.initializer && ts.isStringLiteralLike(node.initializer)) {
9730
+ URL_REF.lastIndex = 0;
9731
+ let match;
9732
+ while ((match = URL_REF.exec(node.initializer.text)) !== null) if (match[1]) ids.add(match[1]);
9733
+ }
9734
+ }
9735
+ ts.forEachChild(node, refsFrom);
9736
+ };
9737
+ for (const element of elements) if (element.tag === "mask") refsFrom(element.node);
9738
+ return ids;
9739
+ }
8494
9740
  const noHardcodedColor = {
8495
9741
  id: "no-hardcoded-color",
8496
9742
  description: "Colors must come from design tokens: no hex/rgb/oklch literals, arbitrary Tailwind colors or default-palette classes.",
@@ -8498,71 +9744,174 @@ const noHardcodedColor = {
8498
9744
  const { tokens } = context.target;
8499
9745
  if (!tokens.has("color")) return;
8500
9746
  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);
9747
+ const maskGradients = maskReferencedGradientIds(context.analysis.elements);
9748
+ const lists = /* @__PURE__ */ new Map();
9749
+ for (const classString of context.analysis.classStrings) {
9750
+ const key = classString.element ?? classString;
9751
+ const list = lists.get(key) ?? [];
9752
+ lists.set(key, list);
8504
9753
  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
- });
9754
+ for (const token of splitClasses(classString.text, classString.start)) {
9755
+ const utility = parseUtility(token.value);
9756
+ const listed = {
9757
+ value: token.value,
9758
+ prefix: tokenColorPrefix(utility, tokens.colorKeys),
9759
+ element
9760
+ };
9761
+ list.push(listed);
9762
+ const allowed = allow.has(token.value.toLowerCase());
9763
+ if (utility.arbitrary !== void 0 && utility.prefix && COLOR_PREFIXES.includes(utility.prefix)) {
9764
+ const value = utility.arbitrary.replace(/^color:/, "").trim();
9765
+ const shade = PALETTE_VARIABLE.exec(value)?.[1];
9766
+ const viaVariable = shade ? paletteColor(`${utility.prefix}-${shade}`) : void 0;
9767
+ if (viaVariable) {
9768
+ const color = tokens.colorKeys.has(viaVariable.key) ? void 0 : parseColor(viaVariable.value);
9769
+ if (!color) continue;
9770
+ listed.color = color;
9771
+ listed.prefix = viaVariable.prefix;
9772
+ listed.tinted = !viaVariable.gray;
9773
+ if (allowed) continue;
9774
+ listed.report = colorReport(context, {
9775
+ start: token.start,
9776
+ end: token.end,
9777
+ what: `\`${token.value}\` is Tailwind's default palette (\`${viaVariable.key}\`), not a design-system color`,
9778
+ color,
9779
+ tinted: !viaVariable.gray,
9780
+ palette: viaVariable.key,
9781
+ prefix: viaVariable.prefix,
9782
+ place: "class",
9783
+ utility,
9784
+ element,
9785
+ classes: classString.text
9786
+ });
9787
+ continue;
9788
+ }
9789
+ const whole = parseColor(value);
9790
+ const literal = whole ? {
9791
+ text: value,
9792
+ color: whole
9793
+ } : findColorLiterals(value)[0];
9794
+ if (!literal) continue;
9795
+ listed.color = literal.color;
9796
+ listed.prefix = whole ? utility.prefix : void 0;
9797
+ if (allowed || allow.has(literal.text.toLowerCase())) continue;
9798
+ listed.report = colorReport(context, {
9799
+ start: token.start,
9800
+ end: token.end,
9801
+ what: `Hardcoded color \`${token.value}\``,
9802
+ color: literal.color,
9803
+ prefix: listed.prefix,
9804
+ place: "class",
9805
+ utility,
9806
+ element,
9807
+ classes: classString.text
9808
+ });
9809
+ continue;
9810
+ }
9811
+ const property = utility.property ? cssColorProperty(utility.property) : void 0;
9812
+ if (property && utility.arbitrary !== void 0) {
9813
+ const value = utility.arbitrary.trim();
9814
+ const whole = parseColor(value);
9815
+ const literal = whole ? {
9816
+ text: value,
9817
+ color: whole
9818
+ } : findColorLiterals(value)[0];
9819
+ if (!literal) continue;
9820
+ listed.color = literal.color;
9821
+ listed.prefix = whole ? property.prefix : void 0;
9822
+ if (allowed || allow.has(literal.text.toLowerCase())) continue;
9823
+ listed.report = colorReport(context, {
9824
+ start: token.start,
9825
+ end: token.end,
9826
+ what: `Hardcoded color \`${token.value}\``,
9827
+ color: literal.color,
9828
+ prefix: listed.prefix,
9829
+ place: "class",
9830
+ utility,
9831
+ element,
9832
+ classes: classString.text
9833
+ });
9834
+ continue;
9835
+ }
9836
+ const palette = paletteColor(utility.base);
9837
+ if (palette && !tokens.colorKeys.has(palette.key)) {
9838
+ const color = parseColor(palette.value);
9839
+ if (!color) continue;
9840
+ listed.color = color;
9841
+ listed.prefix = palette.prefix;
9842
+ listed.tinted = !palette.gray;
9843
+ if (allowed) continue;
9844
+ listed.report = colorReport(context, {
9845
+ start: token.start,
9846
+ end: token.end,
9847
+ what: `\`${token.value}\` is Tailwind's default palette, not a design-system color`,
9848
+ color,
9849
+ tinted: !palette.gray,
9850
+ palette: palette.key,
9851
+ prefix: palette.prefix,
9852
+ place: "class",
9853
+ utility,
9854
+ element,
9855
+ classes: classString.text
9856
+ });
9857
+ continue;
9858
+ }
9859
+ const keyword = keywordColor(utility, tokens.colorKeys);
9860
+ const tint = keyword ? void 0 : keywordColor(utility, tokens.colorKeys, true);
9861
+ if (tint && colorRole(tint.prefix) === "text" && !(element && isInsideMask(element))) {
9862
+ listed.color = tint.color;
9863
+ listed.prefix = tint.prefix;
9864
+ }
9865
+ if (keyword && !(element && isInsideMask(element))) {
9866
+ listed.color = keyword.color;
9867
+ listed.prefix = keyword.prefix;
9868
+ if (allowed) continue;
9869
+ listed.keyword = {
9870
+ start: token.start,
9871
+ end: token.end,
9872
+ what: `\`${token.value}\` is a fixed color, not a design-system color`,
9873
+ color: keyword.color,
9874
+ tinted: false,
9875
+ keyword: true,
9876
+ prefix: keyword.prefix,
9877
+ place: "class",
9878
+ utility,
9879
+ element,
9880
+ classes: classString.text
9881
+ };
9882
+ }
8564
9883
  }
8565
9884
  }
9885
+ const surfaces = new Surfaces(context, lists);
9886
+ const texts = [];
9887
+ const fixedTexts = [];
9888
+ for (const list of lists.values()) for (const listed of list) {
9889
+ const text = colorRole(listed.prefix) === "text" && listed.color !== void 0;
9890
+ if (listed.keyword) {
9891
+ if (text) {
9892
+ const placed = surfaces.place(list, listed);
9893
+ texts.push({
9894
+ text: listed,
9895
+ list
9896
+ });
9897
+ if (placed.contrasts) continue;
9898
+ }
9899
+ listed.report = colorReport(context, listed.keyword);
9900
+ } else if (text) (listed.report?.fix ? fixedTexts : texts).push({
9901
+ text: listed,
9902
+ list
9903
+ });
9904
+ }
9905
+ for (const { text, list } of texts) surfaces.holdUnderText(text, list);
9906
+ for (const list of lists.values()) keepStatesApart(list);
9907
+ const stays = new Set(texts.map(({ text }) => text));
9908
+ for (const { text, list } of fixedTexts) {
9909
+ if (text.report?.fix) continue;
9910
+ stays.add(text);
9911
+ surfaces.holdUnderText(text, list);
9912
+ }
9913
+ surfaces.holdUnderInherited(stays);
9914
+ for (const list of lists.values()) for (const { report } of list) if (report) context.report(report);
8566
9915
  for (const { object, element } of context.analysis.styles) for (const prop of object.properties) {
8567
9916
  if (!ts.isPropertyAssignment(prop)) continue;
8568
9917
  const name = propertyName(prop.name);
@@ -8585,13 +9934,16 @@ const noHardcodedColor = {
8585
9934
  if (!isColorAttribute(name)) continue;
8586
9935
  if (resolution.kind === "component" && resolution.component.props.some((p) => p.name === name && p.values?.length)) continue;
8587
9936
  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
- });
9937
+ for (const literal of literalValues(attribute)) {
9938
+ if ((name === "stopColor" || name === "stop-color") && element.tag === "stop" && MASK_LUMINANCE.has(literal.text.toLowerCase()) && (isInsideMask(element) || maskGradients.has(gradientIdOfStop(element) ?? ""))) continue;
9939
+ reportLiteralColors(context, literal, element, {
9940
+ where: `${name}="…"`,
9941
+ prefix,
9942
+ place: "attribute",
9943
+ named: resolution.kind === "intrinsic",
9944
+ allow
9945
+ });
9946
+ }
8595
9947
  }
8596
9948
  }
8597
9949
  }
@@ -8611,7 +9963,7 @@ function reportLiteralColors(context, literal, element, options) {
8611
9963
  }
8612
9964
  for (const match of matches) {
8613
9965
  if (options.allow.has(match.text.toLowerCase())) continue;
8614
- reportColor(context, {
9966
+ const report = colorReport(context, {
8615
9967
  start: start + match.index,
8616
9968
  end: start + match.index + match.text.length,
8617
9969
  what: `Hardcoded color \`${match.text}\` in ${options.where}`,
@@ -8620,9 +9972,11 @@ function reportLiteralColors(context, literal, element, options) {
8620
9972
  place: options.place,
8621
9973
  element
8622
9974
  });
9975
+ if (report) context.report(report);
8623
9976
  }
8624
9977
  }
8625
- function reportColor(context, input) {
9978
+ /** The finding for a color, or undefined when the design system has no color to name. */
9979
+ function colorReport(context, input) {
8626
9980
  const role = colorRole(input.prefix ?? input.utility?.prefix);
8627
9981
  const mode = input.utility?.variants.includes("dark") ? "dark" : void 0;
8628
9982
  const suggestion = context.target.tokens.suggestColor(input.color, {
@@ -8631,13 +9985,56 @@ function reportColor(context, input) {
8631
9985
  tinted: input.tinted,
8632
9986
  mode
8633
9987
  });
8634
- if (!suggestion) return;
8635
- const { match, nearest, reason, sameValue, hueOff } = suggestion;
9988
+ if (!suggestion) return void 0;
9989
+ const { match, nearest, reason, sameValue, hueOff, otherRole, closeStatus } = suggestion;
8636
9990
  const token = nearest.candidate.token;
8637
- const cls = input.prefix && token.tailwind ? `${input.prefix}-${token.tailwind}` : void 0;
8638
- const cssVar = cssReference(token);
9991
+ const { cls, replacement, advice, edit } = writing(input, token);
8639
9992
  const close = match !== void 0;
9993
+ const held = close && input.palette && nearest.distance >= .02 ? heldBack(input.palette, valueIn(nearest.candidate, mode), nearest.distance) : void 0;
9994
+ const status = close && closeStatus ? statusTooClose(input, mode, closeStatus, token, advice) : void 0;
8640
9995
  const variant = cls && input.element ? variantApplying(context, input.element, cls) : void 0;
9996
+ const same = sameValue.length ? `, same value as ${sameValue.map((t) => t.name).join(", ")}` : "";
9997
+ const inMode = mode && token.modes?.[mode] !== void 0 ? ` in ${mode} mode` : "";
9998
+ const value = valueIn(nearest.candidate, mode);
9999
+ const found = `${token.name}${inMode} (${formatDistance(input.color, value)}${same})`;
10000
+ const opacityGap = formatOpacityGap(input.color, value, `${token.name}${inMode}`);
10001
+ const tinted = input.tinted ?? isTinted(input.color);
10002
+ const noHue = !close && reason === "hue" && tinted;
10003
+ const keywordText = input.keyword === true && role === "text";
10004
+ const keptBack = input.keyword && close && edit && !held && !status ? keywordText ? "White or black text depends on the surface under it, so this is not applied automatically: use the foreground token of that surface." : nearest.distance >= .02 ? "Only a token of exactly this color is applied automatically." : void 0 : void 0;
10005
+ let message;
10006
+ if (close) {
10007
+ message = `${input.what}. ${nearest.distance < .02 ? "Matches token" : "Nearest token"} ${found}`;
10008
+ if (advice) message += ` → ${advice}`;
10009
+ if (variant) message += `, or use ${variant}`;
10010
+ message += ".";
10011
+ if (opacityGap) message += ` ${opacityGap}`;
10012
+ if (status) message += ` ${status}`;
10013
+ else if (held) message += ` ${held}`;
10014
+ else if (keptBack) message += ` ${keptBack}`;
10015
+ } else if (noHue) message = `${input.what}. ${NO_TOKEN_HAS_HUE}`;
10016
+ else message = `${input.what}. ${noMatch(reason, role, found, hueOff)}${meantForAnother(otherRole, input.color, mode)}${opacityGap ? ` ${opacityGap}` : ""} Pick the semantic token that fits.`;
10017
+ const owner = !variant && input.prefix && input.element ? variantProp(context, input.element, input.prefix) : void 0;
10018
+ if (owner) message += ` <${owner.component}> already sets ${input.prefix}-* through \`${owner.prop}\`; prefer a variant over overriding it.`;
10019
+ return {
10020
+ start: input.start,
10021
+ end: input.end,
10022
+ message,
10023
+ suggestion: close ? variant ?? replacement : void 0,
10024
+ fix: close && edit && !held && !status && !keptBack ? [{
10025
+ range: [input.start, input.end],
10026
+ text: edit
10027
+ }] : void 0,
10028
+ severity: noHue || keywordText ? "warning" : void 0
10029
+ };
10030
+ }
10031
+ /**
10032
+ * How code writes `token` where the color is: the class (`bg-primary`), what to suggest, the
10033
+ * message's advice and the text a fix puts in place of the color.
10034
+ */
10035
+ function writing(input, token) {
10036
+ const cls = input.prefix && token.tailwind ? `${input.prefix}-${token.tailwind}` : void 0;
10037
+ const cssVar = cssReference(token);
8641
10038
  let replacement;
8642
10039
  let advice;
8643
10040
  let edit;
@@ -8654,45 +10051,621 @@ function reportColor(context, input) {
8654
10051
  advice = cls ? `\`${cls}\` in className` : void 0;
8655
10052
  }
8656
10053
  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(", ")}` : "";
10054
+ return {
10055
+ cls,
10056
+ replacement,
10057
+ advice,
10058
+ edit
10059
+ };
10060
+ }
10061
+ /**
10062
+ * Why a close match is not applied when a status color of the color's hue is about as close:
10063
+ * `destructive is close too (ΔE 0.071), and it is a status color, so this is not applied
10064
+ * automatically: use \`bg-destructive\` if the color shows a status, otherwise \`bg-brand\`.`
10065
+ */
10066
+ function statusTooClose(input, mode, status, found, advice) {
10067
+ const { token } = status.candidate;
8658
10068
  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.`;
10069
+ const distance = formatDistance(input.color, valueIn(status.candidate, mode));
10070
+ const statusAdvice = writing(input, token).advice || token.name;
10071
+ 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}.`;
10072
+ }
10073
+ /** Color keywords that are no token but set a color (`bg-white`, `border-transparent`). */
10074
+ const KEYWORD_COLORS = /* @__PURE__ */ new Set([
10075
+ "white",
10076
+ "black",
10077
+ "transparent",
10078
+ "current",
10079
+ "inherit"
10080
+ ]);
10081
+ /** The prefix of a class that sets a token's color or a keyword color: `bg` for `bg-primary`. */
10082
+ function tokenColorPrefix(utility, colorKeys) {
10083
+ const key = COLOR_UTILITY.exec(utility.base)?.[1];
10084
+ if (!key || !(colorKeys.has(key) || KEYWORD_COLORS.has(key))) return void 0;
10085
+ return utility.base.slice(0, -key.length - 1);
10086
+ }
10087
+ /** `bg-white`, `border-black`: Tailwind's colors without a shade, as a color utility sets them. */
10088
+ const KEYWORD_CLASS = new RegExp(`^(${COLOR_PREFIXES.join("|")})-(${Object.keys(TAILWIND_KEYWORD_COLORS).join("|")})$`);
10089
+ /**
10090
+ * The white or black an opaque class sets (`bg-white`, `text-black`). With an opacity modifier
10091
+ * (`bg-black/50`, `border-white/10`) it is a tint of whatever is under it, a scrim or a hairline
10092
+ * that suits both modes, so it is left alone: with `tint`, only such a class is read. A theme
10093
+ * that defines `--color-white` (after `--color-*: initial`) makes `bg-white` one of its tokens.
10094
+ */
10095
+ function keywordColor(utility, colorKeys, tint = false) {
10096
+ if (Boolean(utility.modifier) !== tint || utility.arbitrary !== void 0) return void 0;
10097
+ const [, prefix, name] = KEYWORD_CLASS.exec(utility.base) ?? [];
10098
+ if (!prefix || !name || colorKeys.has(name)) return void 0;
10099
+ const color = parseColor(TAILWIND_KEYWORD_COLORS[name] ?? "");
10100
+ return color ? {
10101
+ prefix,
10102
+ color
10103
+ } : void 0;
10104
+ }
10105
+ /** The node around `node`, or undefined at the file. */
10106
+ function parentOf(node) {
10107
+ return ts.isSourceFile(node) ? void 0 : node.parent;
10108
+ }
10109
+ /** Background classes that set no background of their own: what is under them shows through. */
10110
+ const NO_BACKGROUND = /^bg-(?:transparent|current|inherit|none)$/;
10111
+ /** Style properties that set an element's background, and values that set none. */
10112
+ const STYLE_BACKGROUNDS = /* @__PURE__ */ new Set([
10113
+ "background",
10114
+ "backgroundColor",
10115
+ "backgroundImage"
10116
+ ]);
10117
+ const NO_STYLE_BACKGROUND = /^(?:transparent|none|inherit|initial|unset|revert|currentcolor)$/i;
10118
+ /** `var(--primary)`: a custom property, which can be a token's. */
10119
+ const CSS_VARIABLE = /^var\(\s*(--[\w-]+)\s*\)$/;
10120
+ /**
10121
+ * How far apart in lightness (OKLCH L) white or black text and its surface must be for the
10122
+ * surface to be the text's own: white on `bg-indigo-600` or `bg-orange-500` (0.705), not on
10123
+ * `bg-amber-400` or `bg-muted`; black on `bg-yellow-300`, not on `bg-indigo-600`. A token is
10124
+ * judged in each mode the text shows in: shadcn/ui's `primary` is near white in dark mode, so
10125
+ * white text on `bg-primary` is not its own there.
10126
+ */
10127
+ const SURFACE_CONTRAST = .25;
10128
+ /** Whether text of this color stands out from a surface of this color. */
10129
+ function standsOut(surface, text) {
10130
+ return Math.abs(surface.l - text.l) >= SURFACE_CONTRAST;
10131
+ }
10132
+ /**
10133
+ * WCAG's least contrast for large text and the parts of a control. Lightness alone says too
10134
+ * little near black: `text-neutral-600` is 0.44 lighter than black and has 2.7:1 on it, and
10135
+ * `text-neutral-800` 1.6:1.
10136
+ */
10137
+ const TEXT_CONTRAST = 3;
10138
+ /** How hardcoded text reads on a surface: far enough apart in lightness, and in WCAG contrast. */
10139
+ function legibility(surface, text) {
10140
+ return {
10141
+ apart: standsOut(surface, text),
10142
+ contrast: wcagContrast(surface, text) >= TEXT_CONTRAST
10143
+ };
10144
+ }
10145
+ /** The color a text, fill or stroke class hardcodes, or undefined when it sets none. */
10146
+ function paintOf(text) {
10147
+ if (colorRole(text.prefix) !== "text" || !text.color) return void 0;
10148
+ return {
10149
+ color: text.color,
10150
+ variants: parseUtility(text.value).variants,
10151
+ element: text.element
10152
+ };
10153
+ }
10154
+ /**
10155
+ * Where white or black text sits, which says whether the text is the surface's contrast color
10156
+ * (shadcn/ui's `bg-destructive text-white`) or the theme's: the background its own element sets,
10157
+ * in `style` or in its class list, its component's variants set (`<Button>` with `bg-*`
10158
+ * variants), or the nearest element around it that sets one. Any other hardcoded text is placed
10159
+ * the same way, to hold the fix of a background it would not stand out from.
10160
+ */
10161
+ var Surfaces = class {
10162
+ context;
10163
+ lists;
10164
+ byNode = /* @__PURE__ */ new Map();
10165
+ tokenColors = /* @__PURE__ */ new Map();
10166
+ byVar = /* @__PURE__ */ new Map();
10167
+ /** Backgrounds an element sets in `style`: each value's color, by mode. */
10168
+ styled = /* @__PURE__ */ new Map();
10169
+ held = /* @__PURE__ */ new Set();
10170
+ placed = /* @__PURE__ */ new Map();
10171
+ constructor(context, lists) {
10172
+ this.context = context;
10173
+ this.lists = lists;
10174
+ for (const element of context.analysis.elements) this.byNode.set(element.node, element);
10175
+ for (const candidate of context.target.tokens.colors) {
10176
+ const { tailwind, cssVar } = candidate.token;
10177
+ if (tailwind) this.tokenColors.set(tailwind, candidate);
10178
+ if (cssVar) this.byVar.set(cssVar, candidate);
10179
+ }
10180
+ for (const { element, object } of context.analysis.styles) for (const prop of object.properties) {
10181
+ if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue;
10182
+ const name = propertyName(prop.name);
10183
+ if (name === void 0 || !STYLE_BACKGROUNDS.has(name)) continue;
10184
+ const values = ts.isPropertyAssignment(prop) ? valueBranches(prop.initializer) : [prop.name];
10185
+ const colors = this.styled.get(element.node) ?? [];
10186
+ for (const value of values) {
10187
+ const read = this.styleColor(value);
10188
+ if (read) colors.push(read);
10189
+ }
10190
+ if (colors.length) this.styled.set(element.node, colors);
10191
+ }
8669
10192
  }
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
10193
+ /**
10194
+ * Where the text sits, in each mode it shows in: a `dark:` class in dark mode only, another
10195
+ * one in its base mode and in dark mode, unless its class list sets its dark color itself
10196
+ * (`text-black dark:text-white`). It is the surface's own only if it stands out in each.
10197
+ */
10198
+ place(list, text) {
10199
+ let placed = this.placed.get(text);
10200
+ if (!placed) {
10201
+ const paint = paintOf(text);
10202
+ placed = paint ? this.placeInModes(list, text, paint) : { contrasts: true };
10203
+ this.placed.set(text, placed);
10204
+ }
10205
+ return placed;
10206
+ }
10207
+ placeInModes(list, text, paint) {
10208
+ const { variants } = paint;
10209
+ const modes = variants.includes("dark") ? ["dark"] : hasDarkCounterpart(list, text) ? [void 0] : [void 0, "dark"];
10210
+ let under;
10211
+ for (const mode of modes) {
10212
+ const state = mode === "dark" && !variants.includes("dark") ? [...variants, "dark"] : variants;
10213
+ const placed = this.placeIn(list, paint, state, mode);
10214
+ if (!placed.contrasts) return placed;
10215
+ under ??= placed.under;
10216
+ }
10217
+ return {
10218
+ contrasts: true,
10219
+ under
10220
+ };
10221
+ }
10222
+ /**
10223
+ * Takes back the fix of a hardcoded background when hardcoded text on it stays as it is and may
10224
+ * no longer stand out: `bg-black text-white` or `bg-zinc-900 text-white` would become
10225
+ * `bg-primary text-white`, white on near white where `primary` is light (shadcn/ui's dark
10226
+ * mode), and `bg-white text-neutral-800` would become `bg-background text-neutral-800`, dark
10227
+ * gray on near black. The text is on the backgrounds of its own class list, in their states too
10228
+ * (`hover:bg-black`), and on the surface `place` finds for it.
10229
+ */
10230
+ holdUnderText(text, list) {
10231
+ this.holdUnder(text, list, list);
10232
+ const { under } = this.place(list, text);
10233
+ if (under && under !== list) this.holdUnder(text, list, under);
10234
+ }
10235
+ /**
10236
+ * Takes back the fixes of backgrounds whose text color comes from around them, as text color is
10237
+ * inherited: in `<div className="text-neutral-800"><p className="bg-white">` the paragraph is
10238
+ * dark gray on white. An element in between that sets a text color, or a component, which can
10239
+ * set one of its own, ends it. An element with nothing in it (a skeleton's `<span />`) shows no
10240
+ * text.
10241
+ */
10242
+ holdUnderInherited(stays) {
10243
+ for (const list of this.lists.values()) {
10244
+ const element = list[0]?.element;
10245
+ if (!element || !element.children.some((c) => !(ts.isJsxText(c) && c.containsOnlyTriviaWhiteSpaces)) || !list.some((listed) => listed.prefix === "bg" && listed.report?.fix)) continue;
10246
+ for (let node = element.node; node; node = parentOf(node)) {
10247
+ const around = this.byNode.get(node);
10248
+ if (!around) continue;
10249
+ if (this.context.resolve(around).kind !== "intrinsic") break;
10250
+ const aroundList = this.lists.get(around) ?? [];
10251
+ const texts = aroundList.filter((listed) => listed.prefix === "text" && !parseUtility(listed.value).variants.some(stylesAnother));
10252
+ if (!texts.length) continue;
10253
+ if (around !== element) {
10254
+ for (const text of texts) if (stays.has(text)) this.holdUnder(text, aroundList, list);
10255
+ }
10256
+ break;
10257
+ }
10258
+ }
10259
+ }
10260
+ /**
10261
+ * Takes back the fixes of the backgrounds in `under` that the text would not stand out from.
10262
+ * The text loses a token in a mode where it has less than it had on the hardcoded background:
10263
+ * 0.25 of lightness apart, or 3:1 contrast. A token without other modes may still have a dark
10264
+ * one onsystem does not read (`.dark:not(.light)`), so it counts as changing there. The fix
10265
+ * stays when the token keeps that in every mode the text shows in, or the class lists set the
10266
+ * dark-mode background or text (`dark:bg-zinc-900`, `dark:text-white`).
10267
+ */
10268
+ holdUnder(text, list, under) {
10269
+ const paint = paintOf(text);
10270
+ if (!paint) return;
10271
+ const textIsDark = paint.variants.includes("dark");
10272
+ const textInDark = textIsDark || !hasDarkCounterpart(list, text);
10273
+ for (const surface of under) {
10274
+ const { report, color: surfaceColor } = surface;
10275
+ const fix = report?.fix?.[0];
10276
+ if (!report || !fix || !surfaceColor || surface.prefix !== "bg" || this.held.has(surface)) continue;
10277
+ const surfaceUtility = parseUtility(surface.value);
10278
+ if (surfaceUtility.variants.includes("dark")) continue;
10279
+ const before = legibility(surfaceColor, paint.color);
10280
+ if (!before.apart && !before.contrast) continue;
10281
+ const token = this.fixedTo(fix.text);
10282
+ if (!token) continue;
10283
+ const surfaceInDark = !hasDarkCounterpart(under, surface);
10284
+ const modes = Object.entries(token.modes ?? {});
10285
+ const known = modes.length > 0;
10286
+ if (!known) modes.push(["dark", void 0]);
10287
+ const lost = modes.filter(([mode, value]) => {
10288
+ if (!(mode === "dark" ? textInDark && surfaceInDark : !textIsDark)) return false;
10289
+ if (value === void 0) return true;
10290
+ const after = legibility(value, paint.color);
10291
+ return before.apart && !after.apart || before.contrast && !after.contrast;
10292
+ }).map(([mode]) => mode);
10293
+ if (!lost.length) continue;
10294
+ this.held.add(surface);
10295
+ const textUtility = parseUtility(text.value);
10296
+ const shade = KEYWORD_CLASS.exec(textUtility.base)?.[2] ?? "as it is";
10297
+ const name = token.token.name;
10298
+ const why = !known ? `and ${name} may be another color in a dark mode` : `which would not stand out from ${name} in ${lost.includes("dark") ? "dark mode" : `the ${lost[0] ?? ""} mode`}`;
10299
+ const variants = [...textUtility.variants];
10300
+ if (under === list) {
10301
+ for (const variant of surfaceUtility.variants) if (!variants.includes(variant)) variants.push(variant);
10302
+ }
10303
+ const pair = text.tinted ?? isTinted(paint.color) ? void 0 : this.foregroundOf(token, {
10304
+ ...textUtility,
10305
+ variants
10306
+ }, text.prefix);
10307
+ report.message += ` \`${text.value}\` on it stays ${shade}, ${why}, so this is not applied automatically: change the text with it${pair ? `, to \`${pair}\`` : ""}.`;
10308
+ report.fix = void 0;
10309
+ }
10310
+ }
10311
+ /** The token a background's fix puts in: `bg-primary`, or `bg-[var(--chart-bg)]`. */
10312
+ fixedTo(fixed) {
10313
+ const utility = parseUtility(fixed);
10314
+ const variable = utility.arbitrary === void 0 ? void 0 : CSS_VARIABLE.exec(utility.arbitrary.trim())?.[1];
10315
+ if (variable) return this.byVar.get(variable);
10316
+ return this.tokenColors.get(utility.base.slice(3));
10317
+ }
10318
+ /** The text class of the token made for text on `surface`: `text-foreground` on `background`. */
10319
+ foregroundOf(surface, text, prefix) {
10320
+ const key = surface.token.tailwind;
10321
+ if (!key || !prefix) return void 0;
10322
+ const pair = key === "background" ? "foreground" : `${key}-foreground`;
10323
+ return this.tokenColors.has(pair) ? withBase(text, `${prefix}-${pair}`) : void 0;
10324
+ }
10325
+ /**
10326
+ * Where the text sits in one mode, and the class list that says so. Under a tint it does not
10327
+ * stand out from (`bg-white/10` under white text), what is under the tint decides; with nothing
10328
+ * under it in the file, the page's `background` in that mode does.
10329
+ */
10330
+ placeIn(list, text, state, mode) {
10331
+ const { color, element } = text;
10332
+ /** Whether the text is under a tint it does not stand out from, which shows what is under it. */
10333
+ const seen = { through: false };
10334
+ /** What an element's `style`, then its class list, says: undefined to look further out. */
10335
+ const at = (node, classes, component) => {
10336
+ const styled = node ? this.styleVerdict(node, color, mode) : void 0;
10337
+ if (styled === "through") seen.through = true;
10338
+ if (styled !== void 0) return styled === "through" ? void 0 : { contrasts: styled };
10339
+ const says = this.verdict(classes, color, state, mode);
10340
+ if (says === "through") seen.through = true;
10341
+ else if (says !== void 0) return {
10342
+ contrasts: says,
10343
+ under: classes
10344
+ };
10345
+ if (component && variantProp(this.context, component, "bg")) return { contrasts: true };
10346
+ };
10347
+ const own = at(element?.node, list, element);
10348
+ if (own) return own;
10349
+ for (let node = element?.node.parent; node; node = parentOf(node)) {
10350
+ const around = this.byNode.get(node);
10351
+ if (!around) continue;
10352
+ const placed = at(node, this.lists.get(around) ?? [], around);
10353
+ if (placed) return placed;
10354
+ }
10355
+ if (!seen.through) return { contrasts: false };
10356
+ const page = this.tokenColors.get("background");
10357
+ return { contrasts: !page || standsOut(valueIn(page, mode), color) };
10358
+ }
10359
+ /**
10360
+ * What a class list's background says for the text, or undefined when it sets none. Any
10361
+ * background the text stands out from is its own: a list holds the classes of every branch
10362
+ * of a `cn()` object, which never apply together. Then a tint it does not stand out from,
10363
+ * whose surface is under it: in dark mode, `bg-neutral-50 dark:bg-white/10` is a tint over the
10364
+ * page.
10365
+ */
10366
+ verdict(list, text, state, mode) {
10367
+ let verdict;
10368
+ for (const { value } of list) {
10369
+ const says = this.excuses(parseUtility(value), text, state, mode);
10370
+ if (says === true) return true;
10371
+ if (says === "through" || verdict === void 0) verdict = says ?? verdict;
10372
+ }
10373
+ return verdict;
10374
+ }
10375
+ /**
10376
+ * What a background set in `style` says for the text: an inline style is over the classes.
10377
+ * A color known only at run time (a swatch) or an image is a surface of its own.
10378
+ */
10379
+ styleVerdict(node, text, mode) {
10380
+ const colors = this.styled.get(node);
10381
+ if (!colors) return void 0;
10382
+ let verdict = "through";
10383
+ for (const read of colors) {
10384
+ const color = read(mode);
10385
+ if (color === "unknown" || standsOut(color, text)) verdict = true;
10386
+ else if ((color.alpha ?? 1) >= 1) return false;
10387
+ }
10388
+ return verdict;
10389
+ }
10390
+ /** How a style value colors the background: undefined when it sets none. */
10391
+ styleColor(value) {
10392
+ if (value.kind === ts.SyntaxKind.NullKeyword) return void 0;
10393
+ if (ts.isIdentifier(value) && value.text === "undefined") return void 0;
10394
+ if (!ts.isStringLiteralLike(value)) return () => "unknown";
10395
+ const text = value.text.trim();
10396
+ if (!text || NO_STYLE_BACKGROUND.test(text)) return void 0;
10397
+ const variable = CSS_VARIABLE.exec(text)?.[1];
10398
+ const token = variable ? this.byVar.get(variable) : void 0;
10399
+ if (token) return (mode) => valueIn(token, mode);
10400
+ const color = parseColor(text);
10401
+ return () => color ?? "unknown";
10402
+ }
10403
+ /**
10404
+ * Whether a class sets a surface the text contrasts with: a gradient, an image, or a color far
10405
+ * enough from the text's in lightness. An opaque `bg-white` or `bg-black` is the page's own
10406
+ * color, as hardcoded as the text. A tint (`bg-black/60`) is a surface where it stands out;
10407
+ * `bg-white/10` under white text shows what is under it (`through`). Undefined for a class
10408
+ * that sets no background, or sets one only in a state the text is not in: `hover:bg-red-600`
10409
+ * is the surface of `hover:text-white`, not of `text-white`.
10410
+ */
10411
+ excuses(utility, text, state, mode) {
10412
+ const { base, modifier } = utility;
10413
+ if (!utility.variants.every((variant) => state.includes(variant))) return void 0;
10414
+ if (/^(?:from|via)-/.test(base)) return true;
10415
+ if (!base.startsWith("bg-") || NO_BACKGROUND.test(base)) return void 0;
10416
+ if (keywordColor(utility, this.context.target.tokens.colorKeys)) return false;
10417
+ const color = this.backgroundColor(utility, mode);
10418
+ if (color === "unknown") return true;
10419
+ const contrasts = standsOut(color, text);
10420
+ return modifier && !contrasts ? "through" : contrasts;
10421
+ }
10422
+ /** The color a `bg-*` class sets in a mode: a token's, the palette's, or written in it. */
10423
+ backgroundColor(utility, mode) {
10424
+ const key = utility.base.slice(3);
10425
+ const token = this.tokenColors.get(key);
10426
+ if (token) return valueIn(token, mode);
10427
+ const palette = paletteColor(utility.base);
10428
+ if (palette) return parseColor(palette.value) ?? "unknown";
10429
+ const keyword = TAILWIND_KEYWORD_COLORS[key];
10430
+ if (keyword) return parseColor(keyword) ?? "unknown";
10431
+ if (utility.arbitrary === void 0) return "unknown";
10432
+ const value = utility.arbitrary.replace(/^color:/, "").trim();
10433
+ const shade = PALETTE_VARIABLE.exec(value)?.[1];
10434
+ const variable = shade ? void 0 : CSS_VARIABLE.exec(value)?.[1];
10435
+ const viaVariable = variable ? this.byVar.get(variable) : void 0;
10436
+ if (viaVariable) return valueIn(viaVariable, mode);
10437
+ const color = shade ? paletteColor(`bg-${shade}`)?.value : value;
10438
+ return color && parseColor(color) || "unknown";
10439
+ }
10440
+ };
10441
+ /**
10442
+ * Whether the class list sets the color of `listed` for dark mode too: `text-black dark:text-white`,
10443
+ * `bg-white dark:bg-zinc-900`. Only a class that adds `dark:` and nothing else to its variants,
10444
+ * whatever its color: `dark:bg-darkgray-50` names a color of a config onsystem may not read.
10445
+ */
10446
+ function hasDarkCounterpart(list, listed) {
10447
+ const { prefix } = listed;
10448
+ if (prefix === void 0) return false;
10449
+ const { variants } = parseUtility(listed.value);
10450
+ if (variants.includes("dark")) return false;
10451
+ return list.some((other) => {
10452
+ if (other === listed) return false;
10453
+ const theirs = parseUtility(other.value);
10454
+ if (other.prefix !== prefix && !(other.prefix === void 0 && setsColor(theirs, prefix))) return false;
10455
+ return theirs.variants.includes("dark") && theirs.variants.every((variant) => variant === "dark" || variants.includes(variant)) && variants.every((variant) => theirs.variants.includes(variant));
8681
10456
  });
8682
10457
  }
10458
+ /** Utilities of a color prefix that set no color: `bg-cover`, `text-sm`, `stroke-2`. */
10459
+ const NOT_A_COLOR = {
10460
+ bg: /^bg-(?:auto|cover|contain|fixed|local|scroll|repeat.*|no-repeat|center|(?:top|bottom|left|right)(?:-.*)?|clip-.*|origin-.*|blend-.*|gradient-.*|linear.*|radial.*|conic.*|none|size-.*|position-.*|\[url\(.*)$/,
10461
+ text: /^text-(?:xs|sm|base|lg|\d?xl|left|center|right|justify|start|end|wrap|nowrap|balance|pretty|ellipsis|clip|\[\d.*)$/,
10462
+ fill: /^fill-none$/,
10463
+ stroke: /^stroke-(?:none|\d.*|\[\d.*)$/
10464
+ };
10465
+ /** Whether a class of no known color sets the color of `prefix`: `dark:bg-darkgray-50`. */
10466
+ function setsColor(utility, prefix) {
10467
+ if (!utility.base.startsWith(`${prefix}-`) || COLOR_UTILITY.exec(utility.base) === null) return false;
10468
+ if (utility.prefix !== void 0 && utility.prefix !== prefix) return false;
10469
+ return !NOT_A_COLOR[prefix]?.test(utility.base);
10470
+ }
10471
+ /**
10472
+ * Takes back a fix that would make a class under a condition (`hover:`, `focus-visible:`, `md:`)
10473
+ * the same color as the class without it: `bg-[#2563eb] hover:bg-[#1d4ed8]` would become
10474
+ * `bg-primary hover:bg-primary`, and the hover would change nothing. The state class gives way
10475
+ * first; a base class does when the state class already has the token it would get. `dark:` is
10476
+ * no such condition: dark classes are compared with the tokens' dark values, where the same
10477
+ * token is right. Two hardcoded classes of exactly the same color keep their fixes: the
10478
+ * condition changed nothing before either (`border-red-500 focus:border-red-500` keeps an error
10479
+ * border red on focus), and a hardcoded color is the same in every mode. A token class is not:
10480
+ * the class list's `bg-primary` is another color in dark mode than a `hover:bg-[#171717]` next
10481
+ * to it. A `dark:` state class starts from the list's `dark:` class, when it has one.
10482
+ */
10483
+ function keepStatesApart(list) {
10484
+ const after = (listed) => listed.report?.fix?.[0]?.text ?? listed.value;
10485
+ for (const pass of ["state", "base"]) for (const listed of list) {
10486
+ const { report } = listed;
10487
+ if (!report?.fix?.[0]) continue;
10488
+ for (const other of list) {
10489
+ if (other === listed || oneColor(listed.color, other.color)) continue;
10490
+ const [stateListed, baseListed] = pass === "state" ? [listed, other] : [other, listed];
10491
+ const state = parseUtility(after(stateListed));
10492
+ const base = parseUtility(after(baseListed));
10493
+ const conditions = conditionsBetween(state, base);
10494
+ if (!conditions || hasDarkBase(list, stateListed, baseListed, state, base, after)) continue;
10495
+ 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`;
10496
+ const apart = state.modifier ? "such as with another opacity modifier" : `such as with \`${withBase({
10497
+ ...state,
10498
+ modifier: "/90"
10499
+ }, state.base)}\``;
10500
+ report.message += ` ${had}, so with this fix \`${conditions}\` would change nothing: it is not applied automatically. Keep the two apart, ${apart}.`;
10501
+ report.fix = void 0;
10502
+ break;
10503
+ }
10504
+ }
10505
+ }
10506
+ /**
10507
+ * Below this ΔE two hardcoded colors are one color written two ways: a palette class and its
10508
+ * hex (`neutral-100` is 0.00015 from `#f5f5f5`), or an OKLCH value rounded to hex (red-500 is
10509
+ * 0.00097 from `#fb2c36`). SAME_COLOR is too loose for this: `#fafafa` is 0.015 from `#fff`,
10510
+ * and a hover from one to the other is a change the fix must not remove.
10511
+ */
10512
+ const ONE_COLOR = .003;
10513
+ /** Whether two hardcoded colors are exactly one color, so one token for both changes nothing. */
10514
+ function oneColor(a, b) {
10515
+ return a !== void 0 && b !== void 0 && colorDistance(a, b) < ONE_COLOR;
10516
+ }
10517
+ /**
10518
+ * Whether the class list has the base class's dark-mode counterpart, which a `dark:` state class
10519
+ * starts from instead: with `bg-[#171717] dark:bg-[#0a0a0a]`, `dark:hover:` changes the dark
10520
+ * background, not `bg-[#171717]`. Only a class that adds `dark:` and nothing else to the base
10521
+ * class's variants counts: one that adds a condition (`group-hover:`) is off with it.
10522
+ */
10523
+ function hasDarkBase(list, stateListed, baseListed, state, base, after) {
10524
+ if (baseListed.prefix === void 0) return false;
10525
+ if (base.variants.includes("dark") || !state.variants.includes("dark")) return false;
10526
+ return list.some((other) => {
10527
+ if (other === stateListed || other === baseListed || other.prefix !== baseListed.prefix) return false;
10528
+ const { variants } = parseUtility(after(other));
10529
+ return variants.includes("dark") && variants.every((variant) => variant === "dark" || base.variants.includes(variant)) && base.variants.every((variant) => variants.includes(variant));
10530
+ });
10531
+ }
10532
+ /**
10533
+ * The conditions (`hover:`) under which `state` applies and `base` does too, when both are the
10534
+ * same color: undefined when they are not, or `state` adds no condition but `dark:`, or styles
10535
+ * another element (`placeholder:`, `prose-a:`), whose color the base class does not set.
10536
+ */
10537
+ function conditionsBetween(state, base) {
10538
+ if (state.base !== base.base || state.modifier !== base.modifier) return void 0;
10539
+ if (!base.variants.every((variant) => state.variants.includes(variant))) return void 0;
10540
+ const added = state.variants.filter((v) => v !== "dark" && !base.variants.includes(v));
10541
+ if (added.some(stylesAnother)) return void 0;
10542
+ return added.length ? `${added.join(":")}:` : void 0;
10543
+ }
10544
+ /** Variants for a pseudo-element or other elements than the one the class is on. */
10545
+ const OTHER_ELEMENT = /* @__PURE__ */ new Set([
10546
+ "*",
10547
+ "**",
10548
+ "after",
10549
+ "backdrop",
10550
+ "before",
10551
+ "details-content",
10552
+ "file",
10553
+ "first-letter",
10554
+ "first-line",
10555
+ "marker",
10556
+ "placeholder",
10557
+ "selection"
10558
+ ]);
10559
+ /**
10560
+ * Whether a variant styles something else than the element: a pseudo-element (`placeholder:`),
10561
+ * children (`*:`), the typography plugin's elements (`prose-a:`), or an arbitrary selector that
10562
+ * goes on from `&` (`[&>svg]:`, `[&_a]:`, `[&::after]:`), not one that only narrows it
10563
+ * (`[&:hover]:`, `[.dark_&]:`).
10564
+ */
10565
+ function stylesAnother(variant) {
10566
+ if (OTHER_ELEMENT.has(variant) || variant.startsWith("prose-")) return true;
10567
+ const selector = /^\[(.*)\]$/.exec(variant)?.[1];
10568
+ if (!selector?.includes("&")) return false;
10569
+ const after = selector.slice(selector.lastIndexOf("&") + 1).replace(/\([^()]*\)|\[[^\]]*\]/g, "");
10570
+ return /[_>+~]|::/.test(after);
10571
+ }
10572
+ /**
10573
+ * When no token has the color's hue: add one, or keep the color and say why. Between JSX
10574
+ * children a `//` line is text, which suppresses nothing and renders, so that place gets the
10575
+ * `{/* *\/}` form.
10576
+ */
10577
+ 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> */}`).";
10578
+ /**
10579
+ * The most Tailwind v3 and v4 differ on one shade, in ΔE: 0.036 for fuchsia-600,
10580
+ * under 0.01 for the grays. A token this close to a class's shade, and nearer it
10581
+ * than to the family's other shades, can be a v3 project's copy of it.
10582
+ */
10583
+ const PALETTE_DRIFT = .037;
10584
+ /** Tailwind's default palette as colors: family → its shades, lightest first. */
10585
+ const PALETTE_SHADES = /* @__PURE__ */ new Map();
10586
+ /** A family's shades as colors, lightest first: `neutral` → 50, 100, … 950. */
10587
+ function shadesOf(family) {
10588
+ let shades = PALETTE_SHADES.get(family);
10589
+ if (!shades) {
10590
+ shades = [];
10591
+ for (const [shade, value] of Object.entries(TAILWIND_PALETTE[family] ?? {})) {
10592
+ const parsed = parseColor(value);
10593
+ if (parsed) shades.push([shade, parsed]);
10594
+ }
10595
+ PALETTE_SHADES.set(family, shades);
10596
+ }
10597
+ return shades;
10598
+ }
10599
+ /** Index of the color in `colors` nearest to `color`, or -1 when there is none. */
10600
+ function nearestOf(colors, color) {
10601
+ let index = -1;
10602
+ let best = Infinity;
10603
+ colors.forEach(([, value], i) => {
10604
+ const distance = colorDistance(color, value);
10605
+ if (distance < best) [index, best] = [i, distance];
10606
+ });
10607
+ return index;
10608
+ }
10609
+ /** The palette color nearest to `color` across every family (`neutral-500`). */
10610
+ function nearestPaletteColor(color) {
10611
+ const all = Object.keys(TAILWIND_PALETTE).flatMap((family) => shadesOf(family).map(([shade, value]) => [`${family}-${shade}`, value]));
10612
+ return all[nearestOf(all, color)]?.[0];
10613
+ }
10614
+ /**
10615
+ * Why a token that is not an exact match for a palette class (`key`, `neutral-600`)
10616
+ * is suggested but not applied, or undefined when it may be: it is nearer another
10617
+ * shade of the family (Dub's `content-default`, neutral-700, for `text-neutral-600`),
10618
+ * or it is the class's shade but further from it than Tailwind v3 and v4 are from
10619
+ * each other (`content-subtle`, neutral-500, for `text-slate-500`).
10620
+ */
10621
+ function heldBack(key, color, distance) {
10622
+ const [family = "", own = ""] = key.split("-");
10623
+ const shades = shadesOf(family);
10624
+ const from = shades.findIndex(([shade]) => shade === own);
10625
+ const to = nearestOf(shades, color);
10626
+ if (from === -1 || to === -1) return void 0;
10627
+ const steps = to - from;
10628
+ let what;
10629
+ let intended;
10630
+ if (steps) {
10631
+ const count = Math.abs(steps) === 1 ? "one shade" : `${String(Math.abs(steps))} shades`;
10632
+ const way = steps > 0 ? "darker" : "lighter";
10633
+ what = `${count} ${way} (${family}-${shades[to]?.[0] ?? ""})`;
10634
+ intended = `the ${way} color`;
10635
+ } else {
10636
+ if (distance < PALETTE_DRIFT) return void 0;
10637
+ const closest = nearestPaletteColor(color);
10638
+ what = closest && closest !== key ? `closest to ${closest}, not ${key}` : `a different color from ${key}`;
10639
+ intended = "that color";
10640
+ }
10641
+ return `That is ${what}, so it is not applied automatically: use it if ${intended} is intended, otherwise pick the semantic token that fits.`;
10642
+ }
10643
+ /** A token's value where the class applies: a `dark:` class takes its dark-mode value. */
10644
+ function valueIn(candidate, mode) {
10645
+ return mode && candidate.modes?.[mode] || candidate.color;
10646
+ }
10647
+ /** `content-subtle has this color (exact match), but it is meant for text.` */
10648
+ function meantForAnother(other, color, mode) {
10649
+ if (!other) return "";
10650
+ const { token } = other.candidate;
10651
+ const role = tokenRole(token);
10652
+ const how = other.distance < .02 ? "has this color" : "is close";
10653
+ const inMode = mode && token.modes?.[mode] !== void 0 ? ` in ${mode} mode` : "";
10654
+ return ` ${token.name} ${how}${inMode} (${formatDistance(color, valueIn(other.candidate, mode))}), but it is meant for ${role ? ROLE_WORDS[role] : "another role"}.`;
10655
+ }
8683
10656
  const ROLE_WORDS = {
8684
10657
  text: "text",
8685
10658
  surface: "backgrounds",
8686
10659
  line: "borders"
8687
10660
  };
8688
- /** Why no token replaces the color, naming the nearest one (`found`). */
8689
- function noMatch(reason, role, tinted, token, found, hueOff) {
10661
+ /**
10662
+ * Why no token replaces the color, naming the nearest one (`found`). A tinted color
10663
+ * with no token of its hue gets `NO_TOKEN_HAS_HUE` instead, which names none.
10664
+ */
10665
+ function noMatch(reason, role, found, hueOff) {
8690
10666
  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
10667
  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
- }
10668
+ if (reason === "hue") return `No gray token is close; nearest is ${found}, which is tinted.`;
8696
10669
  if (reason === "role" && role) return `No close token is meant for ${ROLE_WORDS[role]}; nearest is ${found}.`;
8697
10670
  return `No close token; nearest is ${found}.`;
8698
10671
  }
@@ -8758,17 +10731,19 @@ function lengthRule(id, description, spec) {
8758
10731
  if (negative && !NEGATIVE_PREFIXES.has(utility.prefix)) continue;
8759
10732
  const replacement = lengthReplacement(context, spec.category, px, utility.prefix);
8760
10733
  if (!replacement || unnamed(replacement)) continue;
10734
+ if (spec.category === "spacing" && Math.abs(px) <= 1 && (!replacement.exact || negative)) continue;
8761
10735
  const fixed = withBase({
8762
10736
  ...utility,
8763
10737
  negative
8764
10738
  }, replacement.cls);
8765
- const message = replacement.exact ? `\`${token.value}\` is ${formatPx(px)}, which is on the ${spec.category} scale: use \`${fixed}\`.` : replacement.pill ? `\`${token.value}\` (${formatPx(px)}) is far above the radius scale, so it reads as fully rounded: use \`${fixed}\`.` : `Hardcoded ${spec.category} \`${token.value}\` (${formatPx(px)}) is off the scale. Nearest: \`${fixed}\` (${formatPx(replacement.px)})` + (replacement.far ? ", too far off to replace automatically." : ".");
10739
+ const relative = !replacement.pill && FONT_RELATIVE.test(utility.arbitrary.trim());
10740
+ const message = relative ? `\`${token.value}\` is font-relative: ${utility.arbitrary.trim()} is ${formatPx(px)} only at a 16px font. Nearest step: \`${fixed}\` (${formatPx(replacement.px)}), which does not grow with the font, so it is not applied automatically.` : replacement.exact ? `\`${token.value}\` is ${formatPx(px)}, which is on the ${spec.category} scale: use \`${fixed}\`.` : replacement.pill ? `\`${token.value}\` (${formatPx(px)}) is far above the radius scale, so it reads as fully rounded: use \`${fixed}\`.` : `Hardcoded ${spec.category} \`${token.value}\` (${formatPx(px)}) is off the scale. Nearest: \`${fixed}\` (${formatPx(replacement.px)})` + (replacement.far ? ", too far off to replace automatically." : ".");
8766
10741
  context.report({
8767
10742
  start: token.start,
8768
10743
  end: token.end,
8769
10744
  message,
8770
10745
  suggestion: fixed,
8771
- fix: replacement.far ? void 0 : [{
10746
+ fix: replacement.far || relative ? void 0 : [{
8772
10747
  range: [token.start, token.end],
8773
10748
  text: fixed
8774
10749
  }]
@@ -8791,17 +10766,19 @@ function lengthRule(id, description, spec) {
8791
10766
  if (negative && !NEGATIVE_PREFIXES.has(prefix)) continue;
8792
10767
  const replacement = values.length === 1 ? lengthReplacement(context, spec.category, px, prefix) : void 0;
8793
10768
  if (replacement && unnamed(replacement)) continue;
10769
+ if (replacement && spec.category === "spacing" && Math.abs(px) <= 1 && (!replacement.exact || negative)) continue;
8794
10770
  const cls = replacement && `${negative ? "-" : ""}${replacement.cls}`;
8795
10771
  const cssVar = replacement?.cssVar && (negative ? `calc(${replacement.cssVar} * -1)` : replacement.cssVar);
8796
10772
  const shown = number !== void 0 ? `${name}: ${raw}` : `${name}: "${raw}"`;
8797
10773
  const size = replacement?.pill ? "fully rounded" : formatPx(replacement?.px ?? 0);
8798
10774
  const advice = replacement ? replacement.tailwind ? `Use \`${cls}\` (${size}) in className instead of an inline style.` : `Use \`${cssVar ?? cls}\` (${size}).` : `Use ${spec.category} tokens instead.`;
10775
+ const relative = number === void 0 && !replacement?.pill && values.some((v) => FONT_RELATIVE.test(v));
8799
10776
  context.report({
8800
10777
  start: init.getStart(context.sourceFile),
8801
10778
  end: init.end,
8802
- message: `Hardcoded ${spec.category} \`${shown}\` in style. ${advice}`,
10779
+ message: relative ? `Hardcoded ${spec.category} \`${shown}\` in style, font-relative: ${formatPx(px)} only at a 16px font. ${advice} No step of the scale grows with the font, so it is not applied automatically.` : `Hardcoded ${spec.category} \`${shown}\` in style. ${advice}`,
8803
10780
  suggestion: replacement ? replacement.tailwind ? cls : cssVar : void 0,
8804
- fix: replacement && !replacement.tailwind && cssVar && replacement.exact ? [{
10781
+ fix: replacement && !replacement.tailwind && cssVar && replacement.exact && !relative ? [{
8805
10782
  range: [init.getStart(context.sourceFile), init.end],
8806
10783
  text: `"${cssVar}"`
8807
10784
  }] : void 0
@@ -8818,6 +10795,8 @@ function lengthRule(id, description, spec) {
8818
10795
  function unnamed(replacement) {
8819
10796
  return replacement.exact && !replacement.tailwind && !replacement.cssVar;
8820
10797
  }
10798
+ /** A length in `em`, relative to the element's font size (`0.5em`, not `0.5rem`). */
10799
+ const FONT_RELATIVE = /^-?(?:\d+\.?\d*|\.\d+)em$/i;
8821
10800
  /** The nearest step is "far" when it is off by more than half the value and more than 4px. */
8822
10801
  const FAR_RATIO = .5;
8823
10802
  const FAR_PX = 4;
@@ -8963,13 +10942,14 @@ function findingKey(diagnostic) {
8963
10942
  const text = diagnostic.source.replace(/\s+/g, " ").trim();
8964
10943
  return (/^(['"`])(.*)\1$/.exec(text)?.[2] ?? text).slice(0, 200);
8965
10944
  }
8966
- /** The baseline's entries, or undefined when the file does not exist. */
10945
+ /** The baseline, or undefined when the file does not exist. */
8967
10946
  function readBaseline(file) {
8968
10947
  let text;
8969
10948
  try {
8970
10949
  text = fs.readFileSync(file, "utf8");
8971
10950
  } catch (error) {
8972
- if (error.code === "ENOENT") return void 0;
10951
+ const { code } = error;
10952
+ if (code === "ENOENT" || code === "ENOTDIR") return void 0;
8973
10953
  throw new ConfigError(`Could not read baseline ${file}: ${error.message}`);
8974
10954
  }
8975
10955
  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 +10961,8 @@ function readBaseline(file) {
8981
10961
  }
8982
10962
  if (!isObject(parsed) || parsed.version !== 1) throw invalid("expected \"version\": 1");
8983
10963
  if (!isObject(parsed.files)) throw invalid("\"files\" must be an object");
10964
+ if (parsed.onsystem !== void 0 && typeof parsed.onsystem !== "string") throw invalid("\"onsystem\" must be a version string");
10965
+ 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
10966
  const entries = /* @__PURE__ */ new Map();
8985
10967
  for (const [name, rules] of Object.entries(parsed.files)) {
8986
10968
  if (!isObject(rules)) throw invalid(`"${name}" must map rules to findings`);
@@ -8996,7 +10978,11 @@ function readBaseline(file) {
8996
10978
  }
8997
10979
  entries.set(name, byRule);
8998
10980
  }
8999
- return entries;
10981
+ return {
10982
+ entries,
10983
+ onsystem: parsed.onsystem,
10984
+ rules: parsed.rules ? new Set(parsed.rules) : void 0
10985
+ };
9000
10986
  }
9001
10987
  /**
9002
10988
  * Removes the findings the baseline accepts for `file`. Within a file and
@@ -9057,6 +11043,71 @@ function updateBaseline(previous, checked, root, disabled = /* @__PURE__ */ new
9057
11043
  }
9058
11044
  return next;
9059
11045
  }
11046
+ /**
11047
+ * The rules an update records as run: those that `ran` now, those the previous baseline lists,
11048
+ * and those with entries in `next`, since a rule with recorded findings ran when they were
11049
+ * recorded. Undefined, as in a baseline written before onsystem recorded rules, when the
11050
+ * previous baseline does not list them and the update may have left out files it covers: the
11051
+ * run checked only a folder or files below the root (`wholeRoot` false), or `next` keeps
11052
+ * entries of files the run did not check. Whatever ran over those files was not recorded, so a
11053
+ * list built from this run could say a rule did not run where it did.
11054
+ */
11055
+ function rulesRun(previous, next, checked, ran, wholeRoot) {
11056
+ if (previous && !previous.rules && (!wholeRoot || [...next.keys()].some((file) => !checked.has(file)))) return;
11057
+ const known = new Set(RULE_IDS);
11058
+ const rules = /* @__PURE__ */ new Set([...previous?.rules ?? [], ...ran]);
11059
+ for (const byRule of next.values()) for (const rule of byRule.keys()) if (known.has(rule)) rules.add(rule);
11060
+ return [...rules];
11061
+ }
11062
+ /**
11063
+ * The baseline without the entries a run over `checked` (file → its findings) shows no longer
11064
+ * occur: a checked file keeps at most as many of each finding as it still has, plus its entries
11065
+ * for `disabled` rules, and files the run did not check keep theirs while they still exist under
11066
+ * `root`. Nothing is added, so a new finding stays new, unlike with `updateBaseline`.
11067
+ */
11068
+ function pruneBaseline(previous, checked, root, disabled) {
11069
+ const next = /* @__PURE__ */ new Map();
11070
+ for (const [file, rules] of previous) {
11071
+ const diagnostics = checked.get(file);
11072
+ if (!diagnostics) {
11073
+ if (fs.existsSync(path.resolve(root, file))) next.set(file, rules);
11074
+ continue;
11075
+ }
11076
+ const found = /* @__PURE__ */ new Map();
11077
+ for (const diagnostic of diagnostics) {
11078
+ const id = `${diagnostic.ruleId}\0${findingKey(diagnostic)}`;
11079
+ found.set(id, (found.get(id) ?? 0) + 1);
11080
+ }
11081
+ const off = disabled(file);
11082
+ const kept = /* @__PURE__ */ new Map();
11083
+ for (const [rule, sources] of rules) {
11084
+ if (off.has(rule)) {
11085
+ kept.set(rule, sources);
11086
+ continue;
11087
+ }
11088
+ const left = /* @__PURE__ */ new Map();
11089
+ for (const [source, count] of sources) {
11090
+ const still = Math.min(count, found.get(`${rule}\0${source}`) ?? 0);
11091
+ if (still) left.set(source, still);
11092
+ }
11093
+ if (left.size) kept.set(rule, left);
11094
+ }
11095
+ if (kept.size) next.set(file, kept);
11096
+ }
11097
+ return next;
11098
+ }
11099
+ /**
11100
+ * New findings of rules that did not run when the baseline was recorded, counted by rule: a
11101
+ * rule that starts to run (an upgrade that finds the tokens it needs, or a rule turned on)
11102
+ * reports what was in the code all along. Empty when the baseline does not say which rules ran.
11103
+ */
11104
+ function unrecordedRules(baseline, diagnostics) {
11105
+ const counts = /* @__PURE__ */ new Map();
11106
+ if (!baseline.rules) return counts;
11107
+ const rules = new Set(RULE_IDS);
11108
+ for (const { ruleId } of diagnostics) if (rules.has(ruleId) && !baseline.rules.has(ruleId)) counts.set(ruleId, (counts.get(ruleId) ?? 0) + 1);
11109
+ return counts;
11110
+ }
9060
11111
  function countBaseline(entries) {
9061
11112
  let findings = 0;
9062
11113
  for (const rules of entries.values()) for (const sources of rules.values()) for (const count of sources.values()) findings += count;
@@ -9065,8 +11116,11 @@ function countBaseline(entries) {
9065
11116
  files: entries.size
9066
11117
  };
9067
11118
  }
9068
- /** Writes the baseline sorted at every level, so it diffs cleanly in review. */
9069
- function writeBaseline(file, entries) {
11119
+ /**
11120
+ * Writes the baseline sorted at every level, so it diffs cleanly in review, with the onsystem
11121
+ * version and the rules that ran when given, and creates its folder.
11122
+ */
11123
+ function writeBaseline(file, entries, about = {}) {
9070
11124
  const sorted = (map) => [...map].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
9071
11125
  const files = {};
9072
11126
  for (const [name, rules] of sorted(entries)) {
@@ -9080,9 +11134,12 @@ function writeBaseline(file, entries) {
9080
11134
  }
9081
11135
  const baseline = {
9082
11136
  version: 1,
11137
+ ...about.onsystem === void 0 ? {} : { onsystem: about.onsystem },
11138
+ ...about.rules === void 0 ? {} : { rules: [...new Set(about.rules)].sort() },
9083
11139
  files
9084
11140
  };
9085
11141
  try {
11142
+ fs.mkdirSync(path.dirname(file), { recursive: true });
9086
11143
  fs.writeFileSync(file, `${JSON.stringify(baseline, null, 2)}\n`);
9087
11144
  } catch (error) {
9088
11145
  throw new ConfigError(`Could not write baseline ${file}: ${error.message}`);
@@ -9096,7 +11153,7 @@ function isObject(value) {
9096
11153
  var name = "onsystem";
9097
11154
  //#endregion
9098
11155
  //#region src/version.ts
9099
- const VERSION = "0.6.0";
11156
+ const VERSION = "0.8.0";
9100
11157
  const NAME = name;
9101
11158
  //#endregion
9102
11159
  //#region src/lint/sarif.ts
@@ -9209,6 +11266,10 @@ function fingerprint$1(uri, identity, occurrence) {
9209
11266
  }
9210
11267
  //#endregion
9211
11268
  //#region src/lint/format.ts
11269
+ /** From this many findings, `pretty` ends with a summary: more than a screen to scroll back through. */
11270
+ const SUMMARY_FROM = 50;
11271
+ /** The palette classes the summary lists. */
11272
+ const TOP_CLASSES = 10;
9212
11273
  const color = (code) => (text, enabled) => enabled ? `\u001b[${code}m${text}\u001b[39m` : text;
9213
11274
  const red = color(31);
9214
11275
  const yellow = color(33);
@@ -9252,6 +11313,7 @@ function formatDiagnostics(results, format, options = {}) {
9252
11313
  lines.push("");
9253
11314
  }
9254
11315
  const total = errors + warnings;
11316
+ if (total >= SUMMARY_FROM) lines.push(...summarize(results), "");
9255
11317
  const notes = [options.baselined === void 0 ? "" : `${options.baselined.toLocaleString("en-US")} in the baseline`, ...options.notes ?? []].filter(Boolean);
9256
11318
  const aside = notes.length ? ` (${notes.join("; ")})` : "";
9257
11319
  const flagged = results.filter((r) => r.diagnostics.length).length;
@@ -9260,6 +11322,42 @@ function formatDiagnostics(results, format, options = {}) {
9260
11322
  if (fixedHint) lines.push(dim(fixedHint, useColor));
9261
11323
  return lines.join("\n");
9262
11324
  }
11325
+ /**
11326
+ * What a long run's findings add up to: the count per rule, as `init`'s dry run gives it, and
11327
+ * the Tailwind palette classes found most, each with the token to use. On a palette-heavy app
11328
+ * (Dub's web app, over 14,000 lines of findings) that reads as a plan for moving to the tokens.
11329
+ */
11330
+ function summarize(results) {
11331
+ const byRule = /* @__PURE__ */ new Map();
11332
+ const pairs = /* @__PURE__ */ new Map();
11333
+ const paletteFiles = /* @__PURE__ */ new Set();
11334
+ let palette = 0;
11335
+ for (const result of results) for (const d of result.diagnostics) {
11336
+ byRule.set(d.ruleId, (byRule.get(d.ruleId) ?? 0) + 1);
11337
+ if (d.ruleId !== "no-hardcoded-color") continue;
11338
+ const utility = parseUtility(d.source);
11339
+ if (!paletteColor(utility.base)) continue;
11340
+ palette++;
11341
+ paletteFiles.add(result.file);
11342
+ const to = (d.fix?.length === 1 ? d.fix[0]?.text : void 0) ?? (d.suggestion && /^[^\s"'=]+$/.test(d.suggestion) ? withBase(utility, d.suggestion) : d.suggestion);
11343
+ const nearest = d.message.includes(". Nearest token ") ? " (nearest token)" : "";
11344
+ const text = `${d.source} → ${to ? `${to}${nearest}` : "no close token"}`;
11345
+ const pair = pairs.get(text) ?? {
11346
+ text,
11347
+ count: 0
11348
+ };
11349
+ pair.count++;
11350
+ pairs.set(text, pair);
11351
+ }
11352
+ const lines = [`By rule: ${[...byRule].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([rule, n]) => `${rule} ${n.toLocaleString("en-US")}`).join(", ")}.`];
11353
+ const top = [...pairs.values()].sort((a, b) => b.count - a.count || a.text.localeCompare(b.text)).slice(0, TOP_CLASSES);
11354
+ if (top.length) {
11355
+ const width = Math.max(...top.map((pair) => pair.count.toLocaleString("en-US").length));
11356
+ lines.push(`Tailwind palette classes: ${palette.toLocaleString("en-US")} in ${plural$1(paletteFiles.size, "file")}. The most common, with the token to use:`, ...top.map((pair) => ` ${pair.count.toLocaleString("en-US").padStart(width)}× ${pair.text}`));
11357
+ }
11358
+ return lines;
11359
+ }
11360
+ /** Escapes a workflow command's message, so `%` and line breaks reach GitHub as written. */
9263
11361
  function escapeData(value) {
9264
11362
  return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
9265
11363
  }
@@ -9763,6 +11861,55 @@ function searchComponents(index, query, limit = 5) {
9763
11861
  }));
9764
11862
  }
9765
11863
  //#endregion
11864
+ //#region src/tokens/imports.ts
11865
+ /** Enough stylesheets for a theme split over files, without reading a whole design system. */
11866
+ const MAX_IMPORTED = 50;
11867
+ /**
11868
+ * The specifiers a stylesheet `@import`s: `@import "./theme.css" layer(base)`,
11869
+ * `@import url(theme.css)`, `@import "@acme/theme"`. Empty for CSS that does not parse.
11870
+ */
11871
+ function cssImports(css) {
11872
+ let root;
11873
+ try {
11874
+ root = postcss.parse(css);
11875
+ } catch {
11876
+ return [];
11877
+ }
11878
+ const out = [];
11879
+ for (const node of root.nodes) {
11880
+ if (node.type !== "atrule" || node.name !== "import") continue;
11881
+ const match = /^\s*(?:url\(\s*)?(?:"([^"]+)"|'([^']+)'|([^\s"')]+)\s*\))/.exec(node.params);
11882
+ const specifier = match?.[1] ?? match?.[2] ?? match?.[3];
11883
+ if (specifier) out.push(specifier);
11884
+ }
11885
+ return out;
11886
+ }
11887
+ /**
11888
+ * The stylesheets `files` import, and the ones those import in turn, each once and in the
11889
+ * order they are imported. The files themselves are not repeated.
11890
+ */
11891
+ function importedStylesheets(files, resolve) {
11892
+ const seen = new Set(files);
11893
+ const out = [];
11894
+ const visit = (file) => {
11895
+ let text;
11896
+ try {
11897
+ text = fs.readFileSync(file, "utf8");
11898
+ } catch {
11899
+ return;
11900
+ }
11901
+ for (const specifier of cssImports(text)) {
11902
+ const target = resolve(specifier, file);
11903
+ if (!target || seen.has(target) || out.length >= MAX_IMPORTED) continue;
11904
+ seen.add(target);
11905
+ out.push(target);
11906
+ visit(target);
11907
+ }
11908
+ };
11909
+ for (const file of files) visit(file);
11910
+ return out;
11911
+ }
11912
+ //#endregion
9766
11913
  //#region src/util/log.ts
9767
11914
  const stderrLogger = {
9768
11915
  info: (message) => process.stderr.write(`[onsystem] ${message}\n`),
@@ -9784,18 +11931,46 @@ var DesignSystem = class {
9784
11931
  searchIndex;
9785
11932
  /**
9786
11933
  * The components the tools offer: all of them, except a library's (MUI's) where the project
9787
- * has its own of that name, a wrapper the team means to be used. The rules still check code
9788
- * that imports the library's directly against the library's.
11934
+ * has its own of that name, a wrapper the team means to be used, and the helpers in a
11935
+ * registry folder's `lib/` (signoff-ui's `Img` and icons). The rules still check code that
11936
+ * imports either directly against them.
9789
11937
  */
9790
11938
  offered;
11939
+ /** The design systems the rules rank, with the registry folders a config lists. */
11940
+ sources;
9791
11941
  constructor(model, config) {
9792
11942
  this.model = model;
9793
11943
  this.config = config;
9794
- this.lint = new LintTarget(model, config, new ModuleResolver(config));
11944
+ this.sources = rankedSources(config);
11945
+ this.lint = new LintTarget(model, this.sources === config.designSystems ? config : {
11946
+ ...config,
11947
+ designSystems: this.sources
11948
+ }, new ModuleResolver(config));
9795
11949
  const own = new Set(model.components.filter((c) => !c.library).flatMap((c) => [c.name, ...c.aliases]));
9796
- this.offered = model.components.filter((c) => !c.library || !(own.has(c.name) || c.parent !== void 0 && own.has(c.parent)));
11950
+ this.offered = model.components.filter((c) => (!c.library || !(own.has(c.name) || c.parent !== void 0 && own.has(c.parent))) && !this.registryHelper(c));
9797
11951
  this.searchIndex = buildSearchIndex(this.offered);
9798
11952
  }
11953
+ /**
11954
+ * The shadcn registry a component was copied from (`@signoff-ui`), when its folder is one
11955
+ * components.json names: a read-only design system, whose code is the registry's.
11956
+ */
11957
+ registryOf(component) {
11958
+ if (component.library) return void 0;
11959
+ const file = component.source.file;
11960
+ for (const source of this.sources ?? []) {
11961
+ if (!source.registry) continue;
11962
+ const folder = source.components.find((g) => matchesGlob(file, g))?.replace(/\/\*\*\/\*\.\{tsx,jsx\}$/, "").replace(/\\(.)/g, "$1");
11963
+ if (folder !== void 0) return {
11964
+ registry: source.registry,
11965
+ folder
11966
+ };
11967
+ }
11968
+ }
11969
+ /** A component in a registry folder's `lib/`: an internal of its items, not one of them. */
11970
+ registryHelper(component) {
11971
+ const from = this.registryOf(component);
11972
+ return from !== void 0 && component.source.file.startsWith(`${from.folder}/lib/`);
11973
+ }
9799
11974
  get root() {
9800
11975
  return this.model.root;
9801
11976
  }
@@ -9881,17 +12056,125 @@ var DesignSystem = class {
9881
12056
  }
9882
12057
  };
9883
12058
  const CONFIGURATION_DOCS = "https://onsystem.vercel.app/docs/configuration";
12059
+ /**
12060
+ * The design systems the rules rank, first to last: zero config's, or, for a config that lists
12061
+ * the folders `npx shadcn add` copies the registries of components.json into (as `init` writes
12062
+ * them), its other components first and each such folder after them, read-only, as zero config
12063
+ * reads them.
12064
+ */
12065
+ function rankedSources(config) {
12066
+ if (config.designSystems || !config.configured) return config.designSystems;
12067
+ const listed = registryFolders(config.root, config.tsconfig).filter((f) => config.components.some((g) => matchesGlob(`${f.dir}/index.tsx`, g)));
12068
+ if (!listed.length) return void 0;
12069
+ const inRegistry = (glob) => {
12070
+ const base = globBase(glob);
12071
+ return listed.some((f) => base === f.dir || base.startsWith(`${f.dir}/`));
12072
+ };
12073
+ return [{
12074
+ source: "components",
12075
+ components: config.components.filter((g) => !inRegistry(g)),
12076
+ imports: 0
12077
+ }, ...listed.map((f) => ({
12078
+ source: `${f.dir} (${f.namespace})`,
12079
+ components: [f.glob],
12080
+ imports: 0,
12081
+ registry: f.namespace
12082
+ }))];
12083
+ }
9884
12084
  /** `border-ring` → token `ring`, for color utilities a token's usage list does not spell out. */
9885
12085
  function matchUtility(base, tokens) {
9886
12086
  const key = COLOR_UTILITY.exec(base)?.[1];
9887
12087
  return key ? tokens.find((t) => t.category === "color" && t.tailwind === key) : void 0;
9888
12088
  }
9889
12089
  /**
12090
+ * Where the design system's files may really be, links resolved: the project, the workspace
12091
+ * and the git repository it is in, the folder a design-system package's config stays in, and
12092
+ * packages reached through a `node_modules` folder in those, which pnpm can link from a store
12093
+ * elsewhere. A glob follows links, and a config can name `../` and absolute paths, so without
12094
+ * this a cloned repository could have the tools return any file on the machine
12095
+ * (`docs/credentials.md` linked to `~/.aws/credentials`).
12096
+ */
12097
+ var Containment = class {
12098
+ /** The folders, as given. */
12099
+ dirs;
12100
+ /** The same folders, links resolved. */
12101
+ roots;
12102
+ reals = /* @__PURE__ */ new Map();
12103
+ /** Files left out, as they were found. */
12104
+ outside = /* @__PURE__ */ new Set();
12105
+ constructor(config) {
12106
+ const around = [
12107
+ workspaceRootOf(config.root),
12108
+ repositoryOf(config.root),
12109
+ config.base,
12110
+ config.designSystemPackage?.boundary
12111
+ ].filter((dir) => dir !== void 0 && !isHomeOrAbove(dir));
12112
+ this.dirs = unique([config.root, ...around].map((dir) => path.resolve(dir)));
12113
+ this.roots = Promise.all(this.dirs.map((dir) => this.real(dir))).then((reals) => reals.filter((real) => real !== void 0));
12114
+ }
12115
+ /** Where `file` really is, links resolved; undefined when it is not there. */
12116
+ real(file) {
12117
+ let real = this.reals.get(file);
12118
+ if (!real) {
12119
+ real = fsp.realpath(file).catch(() => void 0);
12120
+ this.reals.set(file, real);
12121
+ }
12122
+ return real;
12123
+ }
12124
+ /**
12125
+ * `files` whose real path is inside, in their order, one path per real file: the shortest,
12126
+ * so a folder that links back to its parent does not add its files again.
12127
+ */
12128
+ async filter(files) {
12129
+ const roots = await this.roots;
12130
+ const reals = await Promise.all(files.map((file) => this.real(file)));
12131
+ const kept = /* @__PURE__ */ new Map();
12132
+ files.forEach((file, i) => {
12133
+ const real = reals[i];
12134
+ if (real === void 0) return;
12135
+ if (!(roots.some((root) => isInside(root, real)) || isInstalled(real) && this.inNodeModules(file))) {
12136
+ this.outside.add(file);
12137
+ return;
12138
+ }
12139
+ const previous = kept.get(real);
12140
+ if (previous === void 0 || file.length < previous.length) kept.set(real, file);
12141
+ });
12142
+ return [...kept.values()];
12143
+ }
12144
+ /**
12145
+ * Whether `file`, as found, is in a package in a `node_modules` folder of the project, the
12146
+ * workspace or the repository, as one pnpm links from its store is. A link elsewhere in the
12147
+ * project to another project's `node_modules` is not.
12148
+ */
12149
+ inNodeModules(file) {
12150
+ const found = path.resolve(file);
12151
+ return this.dirs.some((dir) => isInside(dir, found) && isInstalled(path.relative(dir, found)));
12152
+ }
12153
+ };
12154
+ /** Whether a path is a file of a package in `node_modules`, not of `.cache` or `.bin` there. */
12155
+ function isInstalled(file) {
12156
+ const parts = file.split(/[\\/]/);
12157
+ const at = parts.lastIndexOf("node_modules");
12158
+ return at !== -1 && at < parts.length - 2 && !(parts[at + 1] ?? ".").startsWith(".");
12159
+ }
12160
+ /** The nearest folder at or above `dir` with a `.git`: the repository it is in. */
12161
+ function repositoryOf(dir) {
12162
+ for (let current = path.resolve(dir);; current = path.dirname(current)) {
12163
+ if (fs.existsSync(path.join(current, ".git"))) return current;
12164
+ if (current === path.dirname(current)) return void 0;
12165
+ }
12166
+ }
12167
+ /** What `inspect` and the server's log say about files that lead outside the project. */
12168
+ function outsideWarning(root, files) {
12169
+ const shown = files.map((file) => relativePath(root, file)).sort();
12170
+ 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).`;
12171
+ }
12172
+ /**
9890
12173
  * Absolute paths matching `patterns` under the root. `node_modules` is skipped
9891
12174
  * even when `exclude` replaces the defaults, except by patterns that name it
9892
- * (tokens published as a package).
12175
+ * (tokens published as a package). Files that lead outside the project are left out.
9893
12176
  */
9894
- async function find(config, patterns) {
12177
+ async function find(config, patterns, contain = new Containment(config)) {
9895
12178
  const options = {
9896
12179
  cwd: config.root,
9897
12180
  absolute: true,
@@ -9901,7 +12184,7 @@ async function find(config, patterns) {
9901
12184
  const named = patterns.filter((p) => p.includes("node_modules"));
9902
12185
  const outside = patterns.filter((p) => !p.includes("node_modules") && p.startsWith("../"));
9903
12186
  const rest = patterns.filter((p) => !p.includes("node_modules") && !p.startsWith("../"));
9904
- return unique((await Promise.all([
12187
+ const found = await Promise.all([
9905
12188
  rest.length ? glob(rest, {
9906
12189
  ...options,
9907
12190
  ignore: unique(["**/node_modules/**", ...exclude])
@@ -9923,7 +12206,8 @@ async function find(config, patterns) {
9923
12206
  ])
9924
12207
  });
9925
12208
  })
9926
- ])).flat()).sort();
12209
+ ]);
12210
+ return (await contain.filter(unique(found.flat()))).sort();
9927
12211
  }
9928
12212
  /** The design system's own component files, as absolute paths. */
9929
12213
  function componentFiles(config) {
@@ -9934,28 +12218,51 @@ async function tokenFiles(config) {
9934
12218
  return (await resolveFiles(config)).tokens.map((t) => t.file);
9935
12219
  }
9936
12220
  async function resolveFiles(config) {
9937
- const components = await find(config, config.components);
9938
- const docs = await find(config, config.docs);
12221
+ const contain = new Containment(config);
12222
+ const components = await find(config, config.components, contain);
12223
+ const docs = await find(config, config.docs, contain);
9939
12224
  const tokens = [];
9940
12225
  const seen = /* @__PURE__ */ new Set();
12226
+ const isNew = async (file) => {
12227
+ const real = await contain.real(file) ?? path.resolve(file);
12228
+ if (seen.has(real)) return false;
12229
+ seen.add(real);
12230
+ return true;
12231
+ };
9941
12232
  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
- }
12233
+ for (const source of sources) for (const file of await find(config, [source.path], contain)) if (await isNew(file)) tokens.push({
12234
+ file,
12235
+ prefix: source.prefix
12236
+ });
9950
12237
  };
9951
12238
  await add(config.tokens.filter((t) => !t.fallback));
12239
+ if (config.tokensDetected) {
12240
+ const found = tokens.map((t) => path.resolve(t.file));
12241
+ for (const file of await contain.filter(linkedStylesheets(config, found))) if (await isNew(file)) tokens.push({ file });
12242
+ }
9952
12243
  if (!tokens.length) await add(config.tokens.filter((t) => t.fallback));
9953
12244
  return {
9954
12245
  components,
9955
12246
  tokens,
9956
- docs
12247
+ docs,
12248
+ outside: [...contain.outside]
9957
12249
  };
9958
12250
  }
12251
+ /**
12252
+ * The stylesheets zero config reads besides the ones it found: those they `@import` (by a
12253
+ * path, or from a workspace package: `@import "@acme/theme"`), then those that set the colors
12254
+ * a Tailwind v3 config maps, in the workspace package of a shared preset it reads (Dub's
12255
+ * `@dub/tailwind-config` and its `themes.css`), with what they import. May repeat a file.
12256
+ */
12257
+ function linkedStylesheets(config, found) {
12258
+ const resolve = stylesheetResolver(config.root);
12259
+ const preset = config.tailwindConfig ? presetStylesheets(readTailwindColors(config.tailwindConfig, presetResolver(config.root)), config.root) : [];
12260
+ return [
12261
+ ...importedStylesheets(found.filter((f) => f.endsWith(".css")), resolve),
12262
+ ...preset,
12263
+ ...importedStylesheets(preset, resolve)
12264
+ ];
12265
+ }
9959
12266
  /** Size and mtime of files, each read once per build. */
9960
12267
  var FileStats = class {
9961
12268
  root;
@@ -9977,11 +12284,11 @@ var FileStats = class {
9977
12284
  /**
9978
12285
  * Everything that, when changed, invalidates the cached model: the tracked
9979
12286
  * files, `dependencies` (project files the components import and the tsconfig
9980
- * chain, known after extraction), the lockfile and the config.
12287
+ * chain, known after extraction), the lockfile, the config and the files left out.
9981
12288
  */
9982
12289
  async function cacheKey(config, files, dependencies, stats = new FileStats(config.root)) {
9983
12290
  const extra = [
9984
- "tsconfig.json",
12291
+ ...PROJECT_CONFIG_FILES,
9985
12292
  "package.json",
9986
12293
  "pnpm-lock.yaml",
9987
12294
  "package-lock.json",
@@ -9996,9 +12303,11 @@ async function cacheKey(config, files, dependencies, stats = new FileStats(confi
9996
12303
  ...dependencies
9997
12304
  ]);
9998
12305
  const { rules: _rules, overrides: _overrides, ...relevant } = config;
12306
+ const outside = files.outside.map((file) => relativePath(config.root, file)).sort();
9999
12307
  return createHash("sha256").update(JSON.stringify({
10000
12308
  version: VERSION,
10001
12309
  config: relevant,
12310
+ outside,
10002
12311
  stats: await stats.of(all)
10003
12312
  })).digest("hex");
10004
12313
  }
@@ -10047,6 +12356,7 @@ async function buildModel(config, options = {}) {
10047
12356
  });
10048
12357
  const docs = [];
10049
12358
  const warnings = [...extracted.warnings];
12359
+ if (files.outside.length) warnings.push(outsideWarning(config.root, files.outside));
10050
12360
  for (const file of files.docs) try {
10051
12361
  docs.push(parseDoc(await fsp.readFile(file, "utf8"), relativePath(config.root, file)));
10052
12362
  } catch (error) {
@@ -10058,11 +12368,11 @@ async function buildModel(config, options = {}) {
10058
12368
  const libraries = await loadLibraries(config);
10059
12369
  warnings.push(...libraries.warnings);
10060
12370
  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);
12371
+ const tailwind = config.tailwindConfig ? readTailwindColors(config.tailwindConfig, presetResolver(config.root)) : void 0;
12372
+ if (tailwind) applyTailwindColors(tokens.tokens, tailwind.colors, tailwind.rgb);
10063
12373
  let radiiFiles = [];
10064
12374
  if (config.tailwindConfig) {
10065
- const radii = readTailwindRadii(config.tailwindConfig, moduleResolver(config.root));
12375
+ const radii = readTailwindRadii(config.tailwindConfig, presetResolver(config.root));
10066
12376
  applyTailwindRadii(tokens.tokens, radii.radii, config.tailwindConfig);
10067
12377
  radiiFiles = radii.files;
10068
12378
  }
@@ -10191,7 +12501,7 @@ var DesignSystemHost = class {
10191
12501
  configFile,
10192
12502
  designSystemPackage && path.join(designSystemPackage.dir, "package.json"),
10193
12503
  ...DETECTION_FILES.map((f) => path.join(root, f)),
10194
- path.resolve(root, tsconfig ?? "tsconfig.json"),
12504
+ ...tsconfig ? [path.resolve(root, tsconfig)] : PROJECT_CONFIG_FILES.map((f) => path.join(root, f)),
10195
12505
  ...readProjectConfig(root, tsconfig).configFiles.filter(isProjectFile)
10196
12506
  ].filter((f) => Boolean(f)).map((f) => path.resolve(f));
10197
12507
  for (const file of files) this.watchedFiles.add(file);
@@ -10285,4 +12595,4 @@ var DesignSystemHost = class {
10285
12595
  }
10286
12596
  };
10287
12597
  //#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 };
12598
+ export { hasOwnConfig as $, TokenIndex as A, parseDoc as B, pruneBaseline as C, isInside as Ct, updateBaseline as D, defaultProjectConfig as Dt, unrecordedRules as E, toPosix as Et, 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, readProjectConfig as Ot, toDtcg as P, excludedBy as Q, parseCssTokens as R, countBaseline as S, SCRIPT_FILE$1 as St, rulesRun as T, relativePath as Tt, ConfigError as U, extractComponents as V, DEFAULT_DOCS as W, configJsonSchema as X, RULE_IDS as Y, defineConfig as Z, NAME as _, workspacePatterns as _t, fingerprint as a, LIBRARIES as at, LEGACY_BASELINE_FILE as b, truncate as bt, silentLogger as c, notReadYet as ct, searchComponents as d, declaredConfig as dt, loadConfig as et, applyFixes as f, detectProject as ft, formatDiagnostics as g, workspacePackageDirs as gt, escapeData as h, isWorkspaceRoot as ht, componentFiles as i, findVariantDefinitions as it, loadTokens as j, RULES as k, stderrLogger as l, PACKAGE_FIELD as lt, uncheckedNotice as m, isDesignSystemName as mt, DesignSystemHost as n, rulesFor as nt, loadDesignSystem as o, LIBRARY_IDS as ot, checkSource as p, findDesignSystemPackage as pt, DEFAULT_TEST_FOLDERS as q, buildModel as r, formatPx as rt, tokenFiles as s, libraryTitle as st, DesignSystem as t, loadConfigWith as tt, buildSearchIndex as u, PackageFinder as ut, VERSION as v, closest as vt, readBaseline as w, matchesGlob as wt, applyBaseline as x, unique as xt, BASELINE_FILE as y, plural$1 as yt, attachDocs as z };