dslinter 0.2.3 → 0.4.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/bin/lib/infer-prop-types-from-ts.mjs +14 -1
  3. package/bin/lib/infer-prop-types-from-ts.test.mjs +32 -0
  4. package/dashboard-dist/assets/{DashboardLayoutAuto-h0gP_iKd.js → DashboardLayoutAuto-BWuyjHPD.js} +1 -1
  5. package/dashboard-dist/assets/DashboardLayoutAuto-CAO6F6-q.css +1 -0
  6. package/dashboard-dist/assets/{axe-DDaE9JTN.js → axe-DHHCqGjV.js} +1 -1
  7. package/dashboard-dist/assets/index-Bxk7tA3F.js +219 -0
  8. package/dashboard-dist/assets/index-D0O_5w5V.css +1 -0
  9. package/dashboard-dist/dslinter-report.json +23929 -0
  10. package/dashboard-dist/index.html +2 -2
  11. package/index.cjs +52 -52
  12. package/package.json +6 -6
  13. package/src/components/CatalogPane.tsx +94 -0
  14. package/src/components/ComponentInspectPane.tsx +125 -125
  15. package/src/components/ComponentPlaygroundPane.tsx +42 -27
  16. package/src/components/DashboardCommandPalette.tsx +9 -0
  17. package/src/components/GovernanceInventoryTabs.tsx +51 -0
  18. package/src/components/GovernancePane.tsx +18 -5
  19. package/src/components/PlaygroundA11yAndCode.tsx +0 -52
  20. package/src/components/PlaygroundControlField.tsx +2 -0
  21. package/src/components/ScoreGauge.test.ts +22 -0
  22. package/src/components/ScoreGauge.tsx +179 -0
  23. package/src/components/Sidebar.tsx +97 -23
  24. package/src/components/TokensPane.tsx +11 -13
  25. package/src/components/controlApiTable.test.ts +15 -0
  26. package/src/components/controlApiTable.ts +4 -0
  27. package/src/components/ui/badge.tsx +5 -5
  28. package/src/dashboard/ComponentCatalog.tsx +10 -1
  29. package/src/dashboard/ComponentPropUsageDetail.tsx +127 -42
  30. package/src/dashboard/ComponentUsageDetails.tsx +39 -9
  31. package/src/dashboard/DashboardBody.tsx +83 -12
  32. package/src/dashboard/ScannedTokenWall.tsx +9 -6
  33. package/src/dashboard/UnusedComponentsList.tsx +74 -0
  34. package/src/dashboard/aggregate.test.ts +381 -12
  35. package/src/dashboard/aggregate.ts +167 -30
  36. package/src/dashboard/mergeTokenCatalog.ts +5 -0
  37. package/src/dashboard/paths.test.ts +18 -1
  38. package/src/dashboard/paths.ts +8 -0
  39. package/src/mcp/agent-query.ts +1 -1
  40. package/src/mcp/css-color.test.ts +52 -0
  41. package/src/mcp/css-color.ts +73 -0
  42. package/src/mcp/rule-catalog.json +3 -3
  43. package/src/mcp/verify-loop.test.ts +24 -0
  44. package/src/mcp/verify-loop.ts +28 -6
  45. package/src/playground/buildPlaygroundEntriesFromReport.test.ts +3 -3
  46. package/src/playground/controls.ts +16 -3
  47. package/src/playground/enrichKitControls.ts +5 -5
  48. package/src/playground/inferKitJsx.test.ts +0 -11
  49. package/src/playground/inferPropTypesFromTs.d.mts +1 -1
  50. package/src/playground/inferPropTypesFromTs.mjs +19 -3
  51. package/src/playground/inferPropTypesFromTs.test.ts +32 -0
  52. package/src/playground/inferPropTypesFromTs.ts +1 -1
  53. package/src/playground/playgroundJoin.ts +34 -0
  54. package/src/playground/propCoerce.ts +2 -2
  55. package/src/playground/snippet.ts +1 -0
  56. package/src/shell/DashboardLayout.tsx +21 -4
  57. package/src/shell/hashRoute.test.ts +9 -0
  58. package/src/shell/hashRoute.ts +6 -0
  59. package/src/types/controls.ts +12 -0
  60. package/src/types/report.ts +1 -1
  61. package/vite/embedTailwindSources.ts +8 -6
  62. package/dashboard-dist/assets/DashboardLayoutAuto-Bja3BuZZ.css +0 -1
  63. package/dashboard-dist/assets/index-B9sZ6wHm.css +0 -1
  64. package/dashboard-dist/assets/index-DIDBt5ed.js +0 -218
@@ -1,9 +1,26 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { truncatePathMiddle } from "./paths";
2
+ import { resolveReportAbsolutePath, truncatePathMiddle } from "./paths";
3
3
 
4
4
  const SAMPLE =
5
5
  "resources/js/components/manage-two-factor.tsx";
6
6
 
7
+ describe("resolveReportAbsolutePath", () => {
8
+ it("returns absolute paths unchanged when under root", () => {
9
+ expect(
10
+ resolveReportAbsolutePath(
11
+ "/repo",
12
+ "/repo/src/components/Button.tsx",
13
+ ),
14
+ ).toBe("/repo/src/components/Button.tsx");
15
+ });
16
+
17
+ it("joins root-relative paths", () => {
18
+ expect(
19
+ resolveReportAbsolutePath("/repo", "src/pages/Home.tsx"),
20
+ ).toBe("/repo/src/pages/Home.tsx");
21
+ });
22
+ });
23
+
7
24
  describe("truncatePathMiddle", () => {
8
25
  it("returns the path unchanged when it fits", () => {
9
26
  expect(truncatePathMiddle(SAMPLE, 100)).toBe(SAMPLE);
@@ -14,6 +14,14 @@ export function shortPath(root: string, fullPath: string): string {
14
14
  return parts.slice(-3).join("/");
15
15
  }
16
16
 
17
+ /** Resolve a report path (absolute or root-relative) to an absolute path. */
18
+ export function resolveReportAbsolutePath(root: string, path: string): string {
19
+ const r = normalizePath(root);
20
+ const p = normalizePath(path);
21
+ if (p.startsWith(r + "/") || p === r) return p;
22
+ return `${r}/${p}`;
23
+ }
24
+
17
25
  /**
18
26
  * Truncate a file path from the middle, preserving the filename and path
19
27
  * separators. Leading path segments are kept when space allows.
@@ -7,7 +7,7 @@ import {
7
7
  import { findingsForComponent } from "../report/findingsForComponent";
8
8
  import { controlsForSpec } from "../playground/controls";
9
9
  import { genericUsageSnippet } from "../playground/snippet";
10
- import { pillarForRule, ruleById, ruleCatalog } from "./rule-catalog";
10
+ import { pillarForRule, ruleCatalog } from "./rule-catalog";
11
11
  import { findingMatchesPath } from "./normalize-paths";
12
12
  import type {
13
13
  ConfigSnapshot,
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { findColorTokenForHex, normalizeHex, parseCssColor } from "./css-color";
3
+ import type { CssTokenDefinition } from "../types/report";
4
+
5
+ function colorDef(name: string, value: string): CssTokenDefinition {
6
+ return {
7
+ name,
8
+ value,
9
+ category: "color",
10
+ scope: "theme",
11
+ path: "theme.css",
12
+ line: 1,
13
+ };
14
+ }
15
+
16
+ describe("css-color", () => {
17
+ it("normalizes shorthand hex", () => {
18
+ expect(normalizeHex("#abc")).toBe("#aabbcc");
19
+ expect(normalizeHex("#DC2626")).toBe("#dc2626");
20
+ });
21
+
22
+ it("parses rgb to hex", () => {
23
+ expect(parseCssColor("rgb(220, 38, 38)")).toBe("#dc2626");
24
+ expect(parseCssColor("rgba(220, 38, 38, 0.5)")).toBe("#dc2626");
25
+ });
26
+
27
+ it("finds token by normalized hex when value is rgb", () => {
28
+ const defs = [colorDef("--color-danger", "rgb(220, 38, 38)")];
29
+ const token = findColorTokenForHex(defs, "#dc2626");
30
+ expect(token?.name).toBe("--color-danger");
31
+ });
32
+
33
+ it("finds token when value is hex and finding uses shorthand", () => {
34
+ const defs = [colorDef("--color-danger", "#dc2626")];
35
+ const token = findColorTokenForHex(defs, "#dc2626");
36
+ expect(token?.name).toBe("--color-danger");
37
+ });
38
+
39
+ it("resolves var() chains before matching", () => {
40
+ const defs = [
41
+ colorDef("--primary", "#93c5fd"),
42
+ colorDef("--color-primary", "var(--primary)"),
43
+ ];
44
+ const token = findColorTokenForHex(defs, "#93c5fd");
45
+ expect(token?.name).toBe("--color-primary");
46
+ });
47
+
48
+ it("returns undefined when no token matches", () => {
49
+ const defs = [colorDef("--color-danger", "rgb(220, 38, 38)")];
50
+ expect(findColorTokenForHex(defs, "#ff0066")).toBeUndefined();
51
+ });
52
+ });
@@ -0,0 +1,73 @@
1
+ import type { CssTokenDefinition } from "../types/report";
2
+
3
+ /** Expand `#abc` → `#aabbcc` (lowercase). */
4
+ export function normalizeHex(raw: string): string | null {
5
+ const s = raw.trim().replace(/^#/, "");
6
+ if (s.length === 3 && /^[0-9a-fA-F]{3}$/.test(s)) {
7
+ return `#${s[0]}${s[0]}${s[1]}${s[1]}${s[2]}${s[2]}`.toLowerCase();
8
+ }
9
+ if (s.length === 6 && /^[0-9a-fA-F]{6}$/.test(s)) {
10
+ return `#${s}`.toLowerCase();
11
+ }
12
+ return null;
13
+ }
14
+
15
+ /** Parse hex or `rgb()`/`rgba()` into normalized `#rrggbb`. */
16
+ export function parseCssColor(raw: string): string | null {
17
+ const trimmed = raw.trim();
18
+ if (trimmed.startsWith("#")) {
19
+ return normalizeHex(trimmed);
20
+ }
21
+ const rgb = trimmed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
22
+ if (rgb) {
23
+ const [, r, g, b] = rgb;
24
+ const toHex = (n: string) => Number(n).toString(16).padStart(2, "0");
25
+ return `#${toHex(r!)}${toHex(g!)}${toHex(b!)}`;
26
+ }
27
+ return null;
28
+ }
29
+
30
+ const VAR_REF_RE = /var\(\s*(--[a-zA-Z0-9][a-zA-Z0-9_-]*)/;
31
+ const MAX_VAR_DEPTH = 8;
32
+
33
+ function resolveTokenValue(
34
+ value: string,
35
+ varMap: Map<string, string>,
36
+ depth = 0,
37
+ ): string | null {
38
+ if (depth > MAX_VAR_DEPTH) return null;
39
+ const trimmed = value.trim();
40
+ const varMatch = trimmed.match(VAR_REF_RE);
41
+ if (varMatch?.[1]) {
42
+ const next = varMap.get(varMatch[1]);
43
+ if (!next) return null;
44
+ return resolveTokenValue(next, varMap, depth + 1);
45
+ }
46
+ return trimmed;
47
+ }
48
+
49
+ /** Find a color token whose resolved value equals the given hex (after normalization). */
50
+ export function findColorTokenForHex(
51
+ definitions: CssTokenDefinition[] | undefined,
52
+ hex: string,
53
+ ): CssTokenDefinition | undefined {
54
+ const target = parseCssColor(hex);
55
+ if (!target || !definitions?.length) return undefined;
56
+
57
+ const varMap = new Map(definitions.map((d) => [d.name, d.value]));
58
+
59
+ const matches = definitions.filter((def) => {
60
+ if (def.category !== "color") return false;
61
+ const resolved = resolveTokenValue(def.value, varMap);
62
+ if (!resolved) return false;
63
+ return parseCssColor(resolved) === target;
64
+ });
65
+
66
+ if (matches.length === 0) return undefined;
67
+
68
+ return matches.sort((a, b) => {
69
+ const aPref = a.name.startsWith("--color-") ? 0 : 1;
70
+ const bPref = b.name.startsWith("--color-") ? 0 : 1;
71
+ return aPref - bPref;
72
+ })[0];
73
+ }
@@ -45,15 +45,15 @@
45
45
  "rule_id": "token-hardcoded-color",
46
46
  "pillar": "token",
47
47
  "default_severity": "warning",
48
- "description": "Hardcoded hex/rgb color instead of design tokens.",
48
+ "description": "Hardcoded hex/rgb color with no matching value in scanned CSS design tokens (className, style, or literals).",
49
49
  "fix_hint": "Replace with a CSS variable or Tailwind theme utility from css_tokens."
50
50
  },
51
51
  {
52
52
  "rule_id": "token-tailwind-arbitrary",
53
53
  "pillar": "token",
54
54
  "default_severity": "warning",
55
- "description": "Tailwind arbitrary value (e.g. bg-[#fff]) bypasses the token system.",
56
- "fix_hint": "Use a named theme utility or CSS custom property."
55
+ "description": "Tailwind arbitrary color or redundant size that duplicates the default scale or @theme spacing tokens.",
56
+ "fix_hint": "Use a named theme utility (e.g. w-20, p-layout-md) instead of an arbitrary bracket value."
57
57
  },
58
58
  {
59
59
  "rule_id": "token-unused-css-var",
@@ -46,4 +46,28 @@ describe("verify-loop", () => {
46
46
  const fix = suggestFix(report, { rule_id: "token-hardcoded-color" });
47
47
  expect(fix?.fix_hint).toBeTruthy();
48
48
  });
49
+
50
+ it("matches rgb token values when suggesting hardcoded color fix", () => {
51
+ const report = loadDemoReport();
52
+ const fix = suggestFix(report, {
53
+ rule_id: "token-hardcoded-color",
54
+ message: "Hardcoded color `#dc2626` — no matching design token",
55
+ });
56
+ // Demo theme defines --color-danger as hex; rgb form should also resolve.
57
+ const rgbReport = {
58
+ ...report,
59
+ css_tokens: {
60
+ ...report.css_tokens!,
61
+ definitions: report.css_tokens!.definitions.map((d) =>
62
+ d.name === "--color-danger" ? { ...d, value: "rgb(220, 38, 38)" } : d,
63
+ ),
64
+ },
65
+ };
66
+ const rgbFix = suggestFix(rgbReport, {
67
+ rule_id: "token-hardcoded-color",
68
+ message: "Hardcoded color `#dc2626` — no matching design token",
69
+ });
70
+ expect(rgbFix?.token).toBe("--color-danger");
71
+ expect(fix?.fix_hint).toBeTruthy();
72
+ });
49
73
  });
@@ -1,4 +1,5 @@
1
1
  import { catalogSummary, componentSpec, findingsForPaths } from "./agent-query";
2
+ import { findColorTokenForHex } from "./css-color";
2
3
  import { ruleById } from "./rule-catalog";
3
4
  import type { LintFinding, WorkspaceReport } from "../types/report";
4
5
 
@@ -13,7 +14,7 @@ export type FixSuggestion = {
13
14
 
14
15
  export function suggestFix(
15
16
  report: WorkspaceReport,
16
- opts: { rule_id: string; path?: string; component?: string },
17
+ opts: { rule_id: string; path?: string; component?: string; message?: string },
17
18
  ): FixSuggestion | null {
18
19
  const entry = ruleById(opts.rule_id);
19
20
  const fix_hint = entry?.fix_hint ?? "Review the finding and align with design system conventions.";
@@ -42,19 +43,39 @@ export function suggestFix(
42
43
  }
43
44
 
44
45
  if (opts.rule_id === "token-hardcoded-color") {
45
- const colorToken = report.css_tokens?.definitions.find(
46
- (d) => d.category === "color",
47
- );
46
+ const hexMatch = opts.message?.match(/`(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))`/);
47
+ const hex = hexMatch?.[1]?.toLowerCase();
48
+ const colorToken =
49
+ hex != null
50
+ ? findColorTokenForHex(report.css_tokens?.definitions, hex)
51
+ : report.css_tokens?.definitions.find((d) => d.category === "color");
48
52
  return {
49
53
  rule_id: opts.rule_id,
50
54
  fix_hint,
51
55
  suggestion: colorToken
52
- ? `Use theme token \`${colorToken.name}\` or Tailwind utility bound to it instead of a hardcoded color.`
53
- : "Replace hardcoded color with a CSS variable or Tailwind theme utility.",
56
+ ? `Use theme token \`${colorToken.name}\` or Tailwind utility bound to it instead of hardcoded \`${hex ?? "color"}\`.`
57
+ : "Replace hardcoded color with a CSS variable or Tailwind theme utility from css_tokens.",
54
58
  token: colorToken?.name,
55
59
  };
56
60
  }
57
61
 
62
+ if (opts.rule_id === "token-tailwind-arbitrary") {
63
+ const utilityMatch = opts.message?.match(/Use `([^`]+)` instead of arbitrary/);
64
+ if (utilityMatch?.[1]) {
65
+ return {
66
+ rule_id: opts.rule_id,
67
+ fix_hint,
68
+ suggestion: `Replace the arbitrary value with \`${utilityMatch[1]}\` from the Tailwind or @theme spacing scale.`,
69
+ };
70
+ }
71
+ return {
72
+ rule_id: opts.rule_id,
73
+ fix_hint,
74
+ suggestion:
75
+ "Replace the arbitrary bracket value with a named theme utility or CSS custom property.",
76
+ };
77
+ }
78
+
58
79
  if (opts.rule_id === "a11y-img-alt" && opts.path) {
59
80
  const decorative =
60
81
  /avatar|icon|logo|decorative|spacer/i.test(opts.path) ||
@@ -141,6 +162,7 @@ export function findingsWithSuggestions(
141
162
  rule_id: f.rule_id,
142
163
  path: f.path,
143
164
  component: componentMatch?.[1],
165
+ message: f.message,
144
166
  });
145
167
  return suggestion ? { ...f, suggestion } : f;
146
168
  });
@@ -86,7 +86,7 @@ describe("playground preview props", () => {
86
86
  it("maps playground control values even when declared_props omits them", () => {
87
87
  const controlOverrides: Record<string, PlaygroundControl[]> = {
88
88
  Demo: [
89
- { key: "children", label: "children", type: "string", default: "" },
89
+ { key: "children", label: "children", type: "node", default: "" },
90
90
  {
91
91
  key: "variant",
92
92
  label: "variant",
@@ -168,8 +168,8 @@ describe("playground preview props", () => {
168
168
  expect(asChild?.type).toBe("boolean");
169
169
 
170
170
  const children = entry!.controls.find((c) => c.key === "children");
171
- expect(children?.type).toBe("string");
172
- if (children?.type === "string") {
171
+ expect(children?.type).toBe("node");
172
+ if (children?.type === "node") {
173
173
  expect(children.default).toBe("Example");
174
174
  }
175
175
 
@@ -19,18 +19,19 @@ export function stringDefaultForProp(key: string): string {
19
19
  }
20
20
 
21
21
  export type PlaygroundStringControl = Extract<PlaygroundControl, { type: "string" }>;
22
+ export type PlaygroundNodeControl = Extract<PlaygroundControl, { type: "node" }>;
22
23
 
23
24
  /** Parts like BreadcrumbSeparator default to an icon when children is omitted. */
24
25
  export function usesIconChildrenFallback(exportName: string): boolean {
25
26
  return exportName.endsWith("Separator");
26
27
  }
27
28
 
28
- export function childrenControl(exportName?: string): PlaygroundStringControl {
29
+ export function childrenControl(exportName?: string): PlaygroundNodeControl {
29
30
  if (exportName && usesIconChildrenFallback(exportName)) {
30
31
  return {
31
32
  key: "children",
32
33
  label: "children",
33
- type: "string",
34
+ type: "node",
34
35
  default: "",
35
36
  placeholder: "Custom separator (chevron when empty)",
36
37
  };
@@ -38,12 +39,22 @@ export function childrenControl(exportName?: string): PlaygroundStringControl {
38
39
  return {
39
40
  key: "children",
40
41
  label: "children",
41
- type: "string",
42
+ type: "node",
42
43
  default: CHILDREN_SLOT_DEFAULT,
43
44
  placeholder: "Slot content",
44
45
  };
45
46
  }
46
47
 
48
+ export function nodeControlForProp(key: string): PlaygroundNodeControl {
49
+ return {
50
+ key,
51
+ label: key,
52
+ type: "node",
53
+ default: defaultStringForProp(key),
54
+ placeholder: "Slot content",
55
+ };
56
+ }
57
+
47
58
  export function childrenPropForPreview(
48
59
  exportName: string | undefined,
49
60
  raw: unknown,
@@ -133,6 +144,8 @@ export function controlsFromDeclaredProps(
133
144
  out.push({ key, label: key, type: "boolean", default: false });
134
145
  } else if (kind === "number") {
135
146
  out.push({ key, label: key, type: "number", default: 0 });
147
+ } else if (kind === "node") {
148
+ out.push(nodeControlForProp(key));
136
149
  } else if (kind === "string") {
137
150
  out.push({
138
151
  key,
@@ -29,7 +29,7 @@ function enrichOneControl(control: PlaygroundControl, slot: KitJsxSlot | undefin
29
29
  const exampleDefault = slotDefaultFromComponent(slot.component);
30
30
  const hint = `${slot.component} children`;
31
31
 
32
- if (control.type === "string") {
32
+ if (control.type === "string" || control.type === "node") {
33
33
  const useExample =
34
34
  exampleDefault !== undefined &&
35
35
  (control.default === control.key || control.default === control.key.toLowerCase());
@@ -102,12 +102,12 @@ function propMetadataForCatalog(
102
102
 
103
103
  const def = definitionForExport(report, spec.export_name);
104
104
  const propOptions = {
105
- ...(def?.declared_prop_options ?? {}),
106
- ...(spec.declared_prop_options ?? {}),
105
+ ...def?.declared_prop_options,
106
+ ...spec.declared_prop_options,
107
107
  };
108
108
  const propDefaults = {
109
- ...(def?.declared_prop_defaults ?? {}),
110
- ...(spec.declared_prop_defaults ?? {}),
109
+ ...def?.declared_prop_defaults,
110
+ ...spec.declared_prop_defaults,
111
111
  };
112
112
  const propKinds = { ...spec.declared_prop_kinds };
113
113
 
@@ -1,4 +1,3 @@
1
- import { createElement } from "react";
2
1
  import { describe, expect, it } from "vitest";
3
2
  import {
4
3
  inferKitJsxSlots,
@@ -7,16 +6,6 @@ import {
7
6
  slotLabelFromComponent,
8
7
  } from "./inferKitJsx";
9
8
 
10
- function Alert(props: { variant?: string; children?: unknown }) {
11
- return createElement("div", props);
12
- }
13
- function AlertTitle(props: { children?: unknown }) {
14
- return createElement("div", props);
15
- }
16
- function AlertDescription(props: { children?: unknown }) {
17
- return createElement("div", props);
18
- }
19
-
20
9
  describe("inferKitJsx", () => {
21
10
  it("reads createElement children bindings (including vite transforms)", () => {
22
11
  const viteTransformed =
@@ -1,7 +1,7 @@
1
1
  import type { Program, TypeChecker, SourceFile, Type } from "typescript";
2
2
  import type { PlaygroundSpec } from "../types/report.js";
3
3
 
4
- export type PropKind = "boolean" | "string" | "number";
4
+ export type PropKind = "boolean" | "string" | "number" | "node";
5
5
 
6
6
  export type CheckerProgram = {
7
7
  program: Program;
@@ -122,12 +122,22 @@ export function findComponentParamType(checker, sf, exportName) {
122
122
  return found;
123
123
  }
124
124
 
125
+ /** @param {ts.TypeChecker} checker @param {ts.Type} type */
126
+ function isReactNodeType(checker, type) {
127
+ const alias = type.aliasSymbol?.escapedName ?? type.aliasSymbol?.name;
128
+ if (alias === "ReactNode" || alias === "ReactElement") return true;
129
+ const text = checker.typeToString(type);
130
+ if (text === "ReactNode" || text === "ReactElement") return true;
131
+ return false;
132
+ }
133
+
125
134
  /**
126
135
  * @param {ts.TypeChecker} checker
127
136
  * @param {ts.Type} type
128
137
  * @returns {PropKind | null}
129
138
  */
130
139
  export function classifyPropType(checker, type) {
140
+ if (isReactNodeType(checker, type)) return "node";
131
141
  const nn = checker.getNonNullableType(type);
132
142
  if (nn.isUnion()) {
133
143
  const parts = nn.types.map((u) =>
@@ -146,10 +156,16 @@ export function classifyPropType(checker, type) {
146
156
  return null;
147
157
  }
148
158
 
149
- /** @param {ts.TypeChecker} checker @param {ts.Type} type */
150
- function followTypeAlias(checker, type) {
159
+ /** @param {ts.TypeChecker} checker @param {ts.Type} type @param {Set<number>} [seen] */
160
+ function followTypeAlias(checker, type, seen = new Set()) {
151
161
  if (type.aliasSymbol) {
152
- return followTypeAlias(checker, checker.getDeclaredTypeOfSymbol(type.aliasSymbol));
162
+ if (seen.has(type.id)) return type;
163
+ seen.add(type.id);
164
+ return followTypeAlias(
165
+ checker,
166
+ checker.getDeclaredTypeOfSymbol(type.aliasSymbol),
167
+ seen,
168
+ );
153
169
  }
154
170
  return type;
155
171
  }
@@ -217,6 +217,38 @@ describe("enrichPlaygroundSpecFromTs", () => {
217
217
  expect(enriched.declared_prop_defaults?.type).toBe("text");
218
218
  expect(enriched.declared_prop_kinds?.type).toBe("string");
219
219
  });
220
+
221
+ it("classifies ReactNode props as node", () => {
222
+ const root = tempProject({
223
+ "src/section.tsx": `
224
+ import type { ReactNode } from "react";
225
+ export function Section({
226
+ children,
227
+ actions,
228
+ }: {
229
+ children: ReactNode;
230
+ actions?: ReactNode;
231
+ }) {
232
+ return null;
233
+ }
234
+ `,
235
+ });
236
+ const bundle = createCheckerProgram(root)!;
237
+ const spec: PlaygroundSpec = {
238
+ id: "Section",
239
+ export_name: "Section",
240
+ rel_path: "src/section.tsx",
241
+ declared_props: ["children", "actions"],
242
+ };
243
+ const enriched = enrichPlaygroundSpecFromTs(
244
+ spec,
245
+ bundle.checker,
246
+ bundle.program,
247
+ root,
248
+ );
249
+ expect(enriched.declared_prop_kinds?.children).toBe("node");
250
+ expect(enriched.declared_prop_kinds?.actions).toBe("node");
251
+ });
220
252
  });
221
253
 
222
254
  describe("createCheckerProgram", () => {
@@ -1,5 +1,5 @@
1
1
  export type { DeclaredPropKind, PlaygroundSpec } from "../types/report";
2
- export type PropKind = "boolean" | "string" | "number";
2
+ export type PropKind = "boolean" | "string" | "number" | "node";
3
3
 
4
4
  export type CheckerProgram = {
5
5
  program: import("typescript").Program;
@@ -168,3 +168,37 @@ export function findPlaygroundJoinSkip(
168
168
  ): PlaygroundJoinSkip | undefined {
169
169
  return skipped?.find((s) => s.export_name === componentId);
170
170
  }
171
+
172
+ /** User-facing hint when a playground spec cannot join to the Vite module graph. */
173
+ export function playgroundJoinDetailMessage(
174
+ skip: PlaygroundJoinSkip | undefined,
175
+ spec: PlaygroundSpec | undefined,
176
+ ): string | null {
177
+ if (skip?.reason === "module_not_found") {
178
+ const { globKey, rel_path } = skip;
179
+ const subdirHint = !rel_path.includes("/")
180
+ ? " This usually means the scanner was run from a subdirectory. Re-run from the project root: npx dslinter ."
181
+ : "";
182
+ if (globKey.startsWith("@dslinter-scan/")) {
183
+ return [
184
+ `Expected module key "${globKey}" but the dslinter Vite plugin did not load it.`,
185
+ `Use <DashboardLayout autoPlayground /> and run via npx dslinter (zero vite.config changes), or add plugins: [dslinter()] from dslinter/vite to vite.config.ts.`,
186
+ `Run the scanner from the project root so rel_path "${rel_path}" matches files under DSLINTER_SCAN_ROOT.`,
187
+ ].join(" ");
188
+ }
189
+ return [
190
+ `Vite glob is missing key "${globKey}" for report path "${rel_path}".`,
191
+ `Prefer <DashboardLayout autoPlayground /> with plugins: [dslinter()] from dslinter/vite, or run npx dslinter init for a custom buildRegistry.ts glob.`,
192
+ subdirHint,
193
+ ]
194
+ .filter(Boolean)
195
+ .join("");
196
+ }
197
+ if (skip?.reason === "export_not_found") {
198
+ return `Module loaded but named export "${skip.export_name}" was not found. Use export function ${skip.export_name}(…) in ${skip.rel_path}.`;
199
+ }
200
+ if (spec) {
201
+ return `Report path: ${spec.rel_path} (export ${spec.export_name}). Use autoPlayground with dslinter/vite, or ensure buildRegistry.ts glob covers this file.`;
202
+ }
203
+ return null;
204
+ }
@@ -8,7 +8,7 @@ import {
8
8
  } from "./controls";
9
9
 
10
10
  export function coerceDeclaredPropKind(v: unknown): DeclaredPropKind | undefined {
11
- if (v === "boolean" || v === "string" || v === "number" || v === "unknown")
11
+ if (v === "boolean" || v === "string" || v === "number" || v === "node" || v === "unknown")
12
12
  return v;
13
13
  return undefined;
14
14
  }
@@ -65,7 +65,7 @@ export function valuesToComponentProps(
65
65
  o[key] = Number.isFinite(n) ? n : 0;
66
66
  continue;
67
67
  }
68
- if (kind === "string") {
68
+ if (kind === "string" || kind === "node") {
69
69
  o[key] = values[key];
70
70
  continue;
71
71
  }
@@ -17,6 +17,7 @@ function valueMatchesPlaygroundDefault(
17
17
  return Number.isFinite(n) && n === control.default;
18
18
  }
19
19
  case "string":
20
+ case "node":
20
21
  case "select":
21
22
  return String(value ?? "") === String(control.default);
22
23
  default:
@@ -14,6 +14,7 @@ import type { TokenCatalog } from "../types/tokenCatalog";
14
14
  import type { DslinterReportState } from "../dashboard/useWorkspaceReport";
15
15
  import { ComponentInspectPane } from "../components/ComponentInspectPane";
16
16
  import { ComponentPlaygroundPane } from "../components/ComponentPlaygroundPane";
17
+ import { CatalogPane } from "../components/CatalogPane";
17
18
  import { GovernancePane } from "../components/GovernancePane";
18
19
  import { Sidebar } from "../components/Sidebar";
19
20
  import { TokensPane } from "../components/TokensPane";
@@ -21,6 +22,7 @@ import { DashboardCommandPalette } from "../components/DashboardCommandPalette";
21
22
  import {
22
23
  componentCatalogNamesFromReport,
23
24
  componentCatalogTreeFromReport,
25
+ resolveFamilyNavigationTarget,
24
26
  } from "../dashboard/aggregate";
25
27
  import { reportWithExtraHidden } from "../dashboard/catalogVisibility";
26
28
  import { resolvePlaygroundEntry } from "../playground/buildPlaygroundEntriesFromReport";
@@ -186,14 +188,17 @@ export function DashboardLayoutInner({
186
188
  return [{ name: item.name, label: item.name }];
187
189
  }
188
190
  return [
189
- { name: item.parent, label: item.parent },
191
+ {
192
+ name: resolveFamilyNavigationTarget(item, catalogNames),
193
+ label: item.parent,
194
+ },
190
195
  ...item.children.map((child) => ({
191
196
  name: child,
192
197
  label: `${item.parent} / ${child}`,
193
198
  })),
194
199
  ];
195
200
  });
196
- }, [catalogReport]);
201
+ }, [catalogReport, catalogNames]);
197
202
 
198
203
  const handleHideFromCatalog = useCallback(
199
204
  (componentName: string) => {
@@ -230,9 +235,22 @@ export function DashboardLayoutInner({
230
235
  onOpenComponent={(name) =>
231
236
  navigate({ view: "component", componentId: name })
232
237
  }
238
+ onOpenCatalog={() => navigate({ view: "catalog" })}
233
239
  />
234
240
  );
235
- } else {
241
+ } else if (route.view === "catalog") {
242
+ main = (
243
+ <CatalogPane
244
+ landing={overview}
245
+ dslinterReportHint={dslinterReportHint}
246
+ dslinterReport={dslinterReport}
247
+ onOpenComponent={(name) =>
248
+ navigate({ view: "component", componentId: name })
249
+ }
250
+ onBackToGovernance={() => navigate({ view: "governance" })}
251
+ />
252
+ );
253
+ } else if (route.view === "component") {
236
254
  const componentId = route.componentId;
237
255
  const entry = resolvePlaygroundEntry(playgroundEntries, componentId);
238
256
  const inCatalog = catalogNames.includes(componentId);
@@ -264,7 +282,6 @@ export function DashboardLayoutInner({
264
282
  playgroundJoinSkips,
265
283
  componentId,
266
284
  )}
267
- onBackToGovernance={() => navigate({ view: "governance" })}
268
285
  onOpenComponent={(name) =>
269
286
  navigate({ view: "component", componentId: name })
270
287
  }