@robomous/ui-core 0.1.1 → 0.2.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 (135) hide show
  1. package/README.md +15 -7
  2. package/dist/{primitives → components}/alert.js +1 -1
  3. package/dist/{primitives → components}/badge.js +1 -1
  4. package/dist/{primitives → components}/button.d.ts +1 -1
  5. package/dist/{primitives → components}/button.js +4 -1
  6. package/dist/{primitives → components}/card.d.ts +1 -1
  7. package/dist/{primitives → components}/card.js +2 -2
  8. package/dist/{primitives → components}/combobox.js +3 -3
  9. package/dist/{primitives → components}/dialog.js +1 -1
  10. package/dist/{primitives → components}/dropdown-menu.js +11 -6
  11. package/dist/{primitives → components}/field.js +2 -4
  12. package/dist/{primitives → components}/input-group.js +1 -1
  13. package/dist/{primitives → components}/input.js +1 -1
  14. package/dist/{primitives → components}/label.js +1 -1
  15. package/dist/components/progress.js +7 -0
  16. package/dist/{primitives → components}/select.d.ts +3 -1
  17. package/dist/{primitives → components}/select.js +6 -4
  18. package/dist/{primitives → components}/separator.js +1 -1
  19. package/dist/{primitives → components}/sheet.js +1 -1
  20. package/dist/{primitives → components}/skeleton.js +1 -1
  21. package/dist/{primitives → components}/sonner.js +17 -12
  22. package/dist/{primitives → components}/table.d.ts +1 -1
  23. package/dist/{primitives → components}/table.js +3 -3
  24. package/dist/{primitives → components}/tabs.js +1 -1
  25. package/dist/{primitives → components}/textarea.js +1 -1
  26. package/dist/{primitives → components}/tooltip.js +1 -1
  27. package/dist/gates/index.d.ts +28 -0
  28. package/dist/gates/index.js +142 -0
  29. package/dist/index.d.ts +31 -32
  30. package/dist/index.js +32 -33
  31. package/dist/{tokens.d.ts → theme/tokens.d.ts} +5 -5
  32. package/dist/{tokens.js → theme/tokens.js} +12 -12
  33. package/package.json +23 -12
  34. package/{shadcn → src/components}/alert.tsx +14 -21
  35. package/src/{primitives → components}/badge.tsx +15 -21
  36. package/src/{primitives → components}/button.tsx +13 -10
  37. package/{shadcn → src/components}/card.tsx +16 -31
  38. package/src/{primitives → components}/combobox.test.tsx +15 -2
  39. package/src/{primitives → components}/combobox.tsx +45 -74
  40. package/src/{primitives/primitives.test.tsx → components/components.test.tsx} +106 -69
  41. package/src/{primitives → components}/dialog.tsx +31 -54
  42. package/src/{primitives → components}/dropdown-menu.tsx +51 -65
  43. package/src/{primitives → components}/field.tsx +48 -62
  44. package/src/{primitives → components}/input-group.tsx +37 -49
  45. package/{shadcn → src/components}/input.tsx +5 -5
  46. package/{shadcn → src/components}/label.tsx +7 -10
  47. package/src/{primitives → components}/progress.tsx +7 -6
  48. package/{shadcn → src/components}/select.tsx +39 -41
  49. package/{shadcn → src/components}/separator.tsx +7 -7
  50. package/src/{primitives → components}/sheet.tsx +27 -44
  51. package/{shadcn → src/components}/skeleton.tsx +3 -3
  52. package/src/{primitives → components}/sonner.test.tsx +12 -4
  53. package/src/components/sonner.tsx +67 -0
  54. package/{shadcn → src/components}/table.tsx +17 -44
  55. package/{shadcn → src/components}/tabs.tsx +16 -26
  56. package/{shadcn → src/components}/textarea.tsx +5 -5
  57. package/{shadcn → src/components}/tooltip.tsx +12 -16
  58. package/src/gates/design.test.ts +188 -0
  59. package/src/gates/index.test.ts +11 -0
  60. package/src/gates/index.ts +165 -0
  61. package/src/gates/tokens.test.ts +215 -0
  62. package/src/harness.test.tsx +1 -1
  63. package/src/index.ts +63 -33
  64. package/src/theme/imports.test.ts +160 -0
  65. package/src/{statusTone.test.ts → theme/statusTone.test.ts} +5 -1
  66. package/src/{styles.css → theme/styles.css} +45 -31
  67. package/src/theme/tailwind.css +646 -0
  68. package/src/{tokens.test.ts → theme/tokens.test.ts} +30 -23
  69. package/src/{tokens.ts → theme/tokens.ts} +12 -12
  70. package/components.json +0 -13
  71. package/dist/lib/button.d.ts +0 -1
  72. package/dist/lib/button.js +0 -4
  73. package/dist/lib/cn.d.ts +0 -18
  74. package/dist/lib/cn.js +0 -21
  75. package/dist/lib/menu.d.ts +0 -1
  76. package/dist/lib/menu.js +0 -16
  77. package/dist/lib/progress.d.ts +0 -3
  78. package/dist/lib/progress.js +0 -6
  79. package/dist/lib/select.d.ts +0 -1
  80. package/dist/lib/select.js +0 -3
  81. package/dist/primitives/progress.js +0 -7
  82. package/gates/canonical.test.mjs +0 -71
  83. package/gates/extensions.test.mjs +0 -373
  84. package/gates/index.d.mts +0 -51
  85. package/gates/index.mjs +0 -514
  86. package/gates/tokens.test.mjs +0 -276
  87. package/shadcn/README.md +0 -13
  88. package/shadcn/badge.tsx +0 -49
  89. package/shadcn/button.tsx +0 -67
  90. package/shadcn/combobox.tsx +0 -299
  91. package/shadcn/dialog.tsx +0 -166
  92. package/shadcn/dropdown-menu.tsx +0 -267
  93. package/shadcn/field.tsx +0 -236
  94. package/shadcn/input-group.tsx +0 -154
  95. package/shadcn/progress.tsx +0 -29
  96. package/shadcn/sheet.tsx +0 -145
  97. package/shadcn/sonner.tsx +0 -49
  98. package/src/lib/button.ts +0 -4
  99. package/src/lib/cn.test.ts +0 -33
  100. package/src/lib/cn.ts +0 -23
  101. package/src/lib/menu.ts +0 -16
  102. package/src/lib/progress.ts +0 -6
  103. package/src/lib/select.ts +0 -4
  104. package/src/primitives/alert.tsx +0 -76
  105. package/src/primitives/card.tsx +0 -103
  106. package/src/primitives/input.tsx +0 -19
  107. package/src/primitives/label.tsx +0 -22
  108. package/src/primitives/select.tsx +0 -192
  109. package/src/primitives/separator.tsx +0 -28
  110. package/src/primitives/skeleton.tsx +0 -13
  111. package/src/primitives/sonner.tsx +0 -65
  112. package/src/primitives/table.tsx +0 -114
  113. package/src/primitives/tabs.tsx +0 -88
  114. package/src/primitives/textarea.tsx +0 -18
  115. package/src/primitives/tooltip.tsx +0 -57
  116. /package/dist/{primitives → components}/alert.d.ts +0 -0
  117. /package/dist/{primitives → components}/badge.d.ts +0 -0
  118. /package/dist/{primitives → components}/combobox.d.ts +0 -0
  119. /package/dist/{primitives → components}/dialog.d.ts +0 -0
  120. /package/dist/{primitives → components}/dropdown-menu.d.ts +0 -0
  121. /package/dist/{primitives → components}/field.d.ts +0 -0
  122. /package/dist/{primitives → components}/input-group.d.ts +0 -0
  123. /package/dist/{primitives → components}/input.d.ts +0 -0
  124. /package/dist/{primitives → components}/label.d.ts +0 -0
  125. /package/dist/{primitives → components}/progress.d.ts +0 -0
  126. /package/dist/{primitives → components}/separator.d.ts +0 -0
  127. /package/dist/{primitives → components}/sheet.d.ts +0 -0
  128. /package/dist/{primitives → components}/skeleton.d.ts +0 -0
  129. /package/dist/{primitives → components}/sonner.d.ts +0 -0
  130. /package/dist/{primitives → components}/tabs.d.ts +0 -0
  131. /package/dist/{primitives → components}/textarea.d.ts +0 -0
  132. /package/dist/{primitives → components}/tooltip.d.ts +0 -0
  133. /package/dist/{statusTone.d.ts → theme/statusTone.d.ts} +0 -0
  134. /package/dist/{statusTone.js → theme/statusTone.js} +0 -0
  135. /package/src/{statusTone.ts → theme/statusTone.ts} +0 -0
@@ -1,8 +1,8 @@
1
- import * as React from "react"
2
- import { cva, type VariantProps } from "class-variance-authority"
3
- import { Tabs as TabsPrimitive } from "radix-ui"
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+ import { Tabs as TabsPrimitive } from "radix-ui";
4
4
 
5
- import { cn } from "@/lib/cn"
5
+ import { cn } from "cn";
6
6
 
7
7
  function Tabs({
8
8
  className,
@@ -13,13 +13,10 @@ function Tabs({
13
13
  <TabsPrimitive.Root
14
14
  data-slot="tabs"
15
15
  data-orientation={orientation}
16
- className={cn(
17
- "group/tabs flex gap-2 data-horizontal:flex-col",
18
- className
19
- )}
16
+ className={cn("group/tabs flex gap-2 data-horizontal:flex-col", className)}
20
17
  {...props}
21
18
  />
22
- )
19
+ );
23
20
  }
24
21
 
25
22
  const tabsListVariants = cva(
@@ -34,15 +31,14 @@ const tabsListVariants = cva(
34
31
  defaultVariants: {
35
32
  variant: "default",
36
33
  },
37
- }
38
- )
34
+ },
35
+ );
39
36
 
40
37
  function TabsList({
41
38
  className,
42
39
  variant = "default",
43
40
  ...props
44
- }: React.ComponentProps<typeof TabsPrimitive.List> &
45
- VariantProps<typeof tabsListVariants>) {
41
+ }: React.ComponentProps<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants>) {
46
42
  return (
47
43
  <TabsPrimitive.List
48
44
  data-slot="tabs-list"
@@ -50,13 +46,10 @@ function TabsList({
50
46
  className={cn(tabsListVariants({ variant }), className)}
51
47
  {...props}
52
48
  />
53
- )
49
+ );
54
50
  }
55
51
 
56
- function TabsTrigger({
57
- className,
58
- ...props
59
- }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
52
+ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
60
53
  return (
61
54
  <TabsPrimitive.Trigger
62
55
  data-slot="tabs-trigger"
@@ -65,24 +58,21 @@ function TabsTrigger({
65
58
  "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
66
59
  "data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
67
60
  "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
68
- className
61
+ className,
69
62
  )}
70
63
  {...props}
71
64
  />
72
- )
65
+ );
73
66
  }
74
67
 
75
- function TabsContent({
76
- className,
77
- ...props
78
- }: React.ComponentProps<typeof TabsPrimitive.Content>) {
68
+ function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
79
69
  return (
80
70
  <TabsPrimitive.Content
81
71
  data-slot="tabs-content"
82
72
  className={cn("flex-1 text-sm outline-none", className)}
83
73
  {...props}
84
74
  />
85
- )
75
+ );
86
76
  }
87
77
 
88
- export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
78
+ export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
@@ -1,6 +1,6 @@
1
- import * as React from "react"
1
+ import * as React from "react";
2
2
 
3
- import { cn } from "@/lib/cn"
3
+ import { cn } from "cn";
4
4
 
5
5
  function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6
6
  return (
@@ -8,11 +8,11 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
8
8
  data-slot="textarea"
9
9
  className={cn(
10
10
  "flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
11
- className
11
+ className,
12
12
  )}
13
13
  {...props}
14
14
  />
15
- )
15
+ );
16
16
  }
17
17
 
18
- export { Textarea }
18
+ export { Textarea };
@@ -1,9 +1,9 @@
1
- "use client"
1
+ "use client";
2
2
 
3
- import * as React from "react"
4
- import { Tooltip as TooltipPrimitive } from "radix-ui"
3
+ import * as React from "react";
4
+ import { Tooltip as TooltipPrimitive } from "radix-ui";
5
5
 
6
- import { cn } from "@/lib/cn"
6
+ import { cn } from "cn";
7
7
 
8
8
  function TooltipProvider({
9
9
  delayDuration = 0,
@@ -15,19 +15,15 @@ function TooltipProvider({
15
15
  delayDuration={delayDuration}
16
16
  {...props}
17
17
  />
18
- )
18
+ );
19
19
  }
20
20
 
21
- function Tooltip({
22
- ...props
23
- }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
24
- return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
21
+ function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
22
+ return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
25
23
  }
26
24
 
27
- function TooltipTrigger({
28
- ...props
29
- }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
30
- return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
25
+ function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
26
+ return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
31
27
  }
32
28
 
33
29
  function TooltipContent({
@@ -43,7 +39,7 @@ function TooltipContent({
43
39
  sideOffset={sideOffset}
44
40
  className={cn(
45
41
  "z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
46
- className
42
+ className,
47
43
  )}
48
44
  {...props}
49
45
  >
@@ -51,7 +47,7 @@ function TooltipContent({
51
47
  <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
52
48
  </TooltipPrimitive.Content>
53
49
  </TooltipPrimitive.Portal>
54
- )
50
+ );
55
51
  }
56
52
 
57
- export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
53
+ export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
@@ -0,0 +1,188 @@
1
+ // @vitest-environment node
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { expect, test } from "vitest";
7
+
8
+ import { competingStatusPaletteIn, statusPaletteIn } from "./index.js";
9
+
10
+ const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
11
+ const read = (rel: string) => readFileSync(path.join(REPO, rel), "utf8");
12
+
13
+ const SOURCE = /\.(?:ts|tsx|css)$/;
14
+
15
+ /**
16
+ * Assembles a Tailwind utility fixture (e.g. "bg-emerald-500") from its
17
+ * prefix and family/shade at runtime. This file scans its own package (it
18
+ * lives under `src/`), so a fixture spelled out contiguously would trip the
19
+ * very gates below on itself — the same reason `HEX` and
20
+ * `RETIRED_ICON_PACKAGE` are built from fragments in the sibling gate files.
21
+ */
22
+ const cls = (prefix: string, familyAndShade: string) => `${prefix}-${familyAndShade}`;
23
+
24
+ /** Every tracked package file matching `SOURCE`. */
25
+ function packageSources(): string[] {
26
+ const listed = spawnSync("git", ["ls-files", "-z", "src"], { cwd: REPO, encoding: "utf8" });
27
+ expect(listed.status, `git ls-files failed: ${listed.stderr}`).toBe(0);
28
+ return listed.stdout.split("\0").filter((name) => SOURCE.test(name));
29
+ }
30
+
31
+ const FOUNDATION_BADGE = ["success", "warning", "info", "quiet"];
32
+
33
+ /** The class string of one variant line in a cva source. */
34
+ function variantClasses(source: string, key: string): string {
35
+ const m = source.match(new RegExp(String.raw`^\s*"?${key}"?:\s*\n?\s*"([^"]*)"`, "m"));
36
+ expect(m, `no variant ${key}`).toBeTruthy();
37
+ return m![1];
38
+ }
39
+
40
+ const BADGE = "src/components/badge.tsx";
41
+
42
+ test("Badge's status vocabulary is exactly the four owned names", () => {
43
+ const source = read("src/components/badge.tsx");
44
+ for (const name of FOUNDATION_BADGE) {
45
+ expect(source.includes(`${name}:`), `badge.tsx is missing the ${name} variant`).toBeTruthy();
46
+ }
47
+ });
48
+
49
+ test("a status Badge paints a soft surface and readable ink, never a coloured stroke", () => {
50
+ const src = read(BADGE);
51
+ const stroke = /\bborder-(?:emerald|amber|sky|success|warning|destructive|primary)\b/;
52
+ for (const k of [...FOUNDATION_BADGE, "destructive"]) {
53
+ expect(variantClasses(src, k), `${k} adds a coloured border`).not.toMatch(stroke);
54
+ }
55
+ expect(variantClasses(src, "success")).toMatch(/\bbg-emerald-500\/10\b/);
56
+ expect(variantClasses(src, "success")).toMatch(/\btext-emerald-700\b/);
57
+ expect(variantClasses(src, "warning")).toMatch(/\bbg-amber-500\/10\b/);
58
+ expect(variantClasses(src, "warning")).toMatch(/\btext-amber-700\b/);
59
+ expect(variantClasses(src, "info")).toMatch(/\bbg-sky-500\/10\b/);
60
+ expect(variantClasses(src, "info")).toMatch(/\btext-sky-700\b/);
61
+ expect(variantClasses(src, "quiet")).toMatch(/\bbg-muted\b/);
62
+ expect(variantClasses(src, "quiet")).toMatch(/\btext-muted-foreground\b/);
63
+ // Official destructive stays on the semantic token, never red-*.
64
+ expect(variantClasses(src, "destructive")).toMatch(/\bbg-destructive\/10\b/);
65
+ expect(src).not.toMatch(/\b(?:bg|text)-red-\d/);
66
+ });
67
+
68
+ test("statusPaletteIn finds the emerald/amber/sky family, and not a token or a comment", () => {
69
+ expect(statusPaletteIn("a.tsx", ` className="${cls("bg", "emerald-500/10")}"`)).toEqual([
70
+ `a.tsx:1: className="${cls("bg", "emerald-500/10")}"`,
71
+ ]);
72
+ expect(
73
+ statusPaletteIn(
74
+ "b.tsx",
75
+ ` className="${cls("border", "amber-400")} dark:${cls("border", "amber-300")}"`,
76
+ ),
77
+ ).toEqual([
78
+ `b.tsx:1: className="${cls("border", "amber-400")} dark:${cls("border", "amber-300")}"`,
79
+ ]);
80
+ expect(statusPaletteIn("c.tsx", ` className="${cls("ring", "sky-500")}"`)).toEqual([
81
+ `c.tsx:1: className="${cls("ring", "sky-500")}"`,
82
+ ]);
83
+ expect(statusPaletteIn("d.tsx", ` className="${cls("shadow", "emerald-500/20")}"`)).toEqual([
84
+ `d.tsx:1: className="${cls("shadow", "emerald-500/20")}"`,
85
+ ]);
86
+ // A token, not the palette.
87
+ expect(statusPaletteIn("e.tsx", ` className="bg-primary text-primary-foreground"`)).toEqual([]);
88
+ // The family name without a shade is not yet a colour.
89
+ expect(statusPaletteIn("f.tsx", ` className="bg-emerald"`)).toEqual([]);
90
+ // A comment recalling the palette states history, not a usage.
91
+ expect(
92
+ statusPaletteIn("g.tsx", ` // never ${cls("bg", "emerald-500")} outside statusTone`),
93
+ ).toEqual([]);
94
+ });
95
+
96
+ test("competingStatusPaletteIn finds a rival colour family, and not the status palette itself", () => {
97
+ expect(competingStatusPaletteIn("a.tsx", ` className="${cls("bg", "green-500")}"`)).toEqual([
98
+ `a.tsx:1: className="${cls("bg", "green-500")}"`,
99
+ ]);
100
+ expect(competingStatusPaletteIn("b.tsx", ` className="${cls("text", "yellow-700")}"`)).toEqual([
101
+ `b.tsx:1: className="${cls("text", "yellow-700")}"`,
102
+ ]);
103
+ expect(competingStatusPaletteIn("c.tsx", ` className="${cls("border", "blue-400")}"`)).toEqual([
104
+ `c.tsx:1: className="${cls("border", "blue-400")}"`,
105
+ ]);
106
+ expect(competingStatusPaletteIn("d.tsx", ` className="${cls("bg", "emerald-500")}"`)).toEqual(
107
+ [],
108
+ );
109
+ expect(
110
+ competingStatusPaletteIn("e.tsx", ` // never ${cls("bg", "red-500")} for a destructive state`),
111
+ ).toEqual([]);
112
+ });
113
+
114
+ test("the status palette lives in exactly Badge and statusTone, nowhere else", () => {
115
+ const ALLOWED_PALETTE_FILES = [
116
+ "src/components/badge.tsx",
117
+ "src/theme/statusTone.ts",
118
+ "src/theme/statusTone.test.ts",
119
+ ];
120
+ const tracked = packageSources();
121
+ expect(
122
+ tracked.length > 0,
123
+ "the scan found no package sources, so it proves nothing",
124
+ ).toBeTruthy();
125
+
126
+ const offenders = tracked
127
+ .filter((file) => !ALLOWED_PALETTE_FILES.includes(file))
128
+ .flatMap((file) => statusPaletteIn(file, readFileSync(path.join(REPO, file), "utf8")));
129
+ expect(
130
+ offenders,
131
+ "the status palette has exactly one home outside Badge and statusTone — read the tone from " +
132
+ `src/theme/statusTone.ts instead:\n${offenders.join("\n")}`,
133
+ ).toEqual([]);
134
+ });
135
+
136
+ test("no competing colour family stands in for the status palette anywhere in the package", () => {
137
+ const tracked = packageSources();
138
+ expect(
139
+ tracked.length > 0,
140
+ "the scan found no package sources, so it proves nothing",
141
+ ).toBeTruthy();
142
+
143
+ const offenders = tracked.flatMap((file) =>
144
+ competingStatusPaletteIn(file, readFileSync(path.join(REPO, file), "utf8")),
145
+ );
146
+ expect(
147
+ offenders,
148
+ `a competing colour family stands in for the status palette:\n${offenders.join("\n")}`,
149
+ ).toEqual([]);
150
+ });
151
+
152
+ /**
153
+ * Every component module is part of the public surface.
154
+ *
155
+ * `src/index.ts` lists its exports one by one rather than re-exporting a
156
+ * directory, which is what keeps the promise auditable — and is also what lets
157
+ * a finished component sit in `src/components/` for months without ever
158
+ * reaching a consumer. `separator.tsx` did exactly that: written, imported by
159
+ * `field.tsx`, never exported, and nothing noticed because every other check
160
+ * here reads the files rather than the surface.
161
+ *
162
+ * A module that is deliberately internal has no home in `src/components/`; move
163
+ * it beside its one caller instead, and this gate stops asking about it.
164
+ */
165
+ test("src/index.ts exports every component module in src/components", () => {
166
+ const listed = spawnSync("git", ["ls-files", "-z", "src/components"], {
167
+ cwd: REPO,
168
+ encoding: "utf8",
169
+ });
170
+ expect(listed.status, `git ls-files failed: ${listed.stderr}`).toBe(0);
171
+
172
+ const modules = listed.stdout
173
+ .split("\0")
174
+ .filter((name) => name.endsWith(".tsx") && !name.includes(".test."))
175
+ .map((name) => path.basename(name, ".tsx"));
176
+ expect(
177
+ modules.length > 0,
178
+ "no component modules were found, so this proves nothing",
179
+ ).toBeTruthy();
180
+
181
+ const surface = read("src/index.ts");
182
+ const missing = modules.filter((name) => !surface.includes(`from "./components/${name}.js"`));
183
+ expect(
184
+ missing,
185
+ "these components exist but no consumer can import them — add an export to src/index.ts, " +
186
+ `or move the module next to its only caller:\n${missing.join("\n")}`,
187
+ ).toEqual([]);
188
+ });
@@ -0,0 +1,11 @@
1
+ // @vitest-environment node
2
+ import { execFileSync } from "node:child_process";
3
+ import { expect, test } from "vitest";
4
+
5
+ test("foundationTokenNames resolves the stylesheet from the built package", () => {
6
+ const script = `import("./dist/gates/index.js").then((m) => { const n = m.foundationTokenNames(); if (!n.includes("background")) { throw new Error("missing token: " + n.join(",")); } console.log("ok"); });`;
7
+ const out = execFileSync(process.execPath, ["--input-type=module", "-e", script], {
8
+ encoding: "utf8",
9
+ });
10
+ expect(out.trim()).toBe("ok");
11
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * @robomous/ui-core/gates — the pure helpers and token facts that keep the
3
+ * design system honest, published so every consumer repo can run the same
4
+ * gates over its own sources with its own extensions registry.
5
+ *
6
+ * Eight exports: four scanners over one file's text, three readers for a
7
+ * stylesheet's blocks and declarations, and the token names read off the
8
+ * stylesheet that ships. DESIGN.md names the rule each one holds.
9
+ */
10
+ import { readFileSync } from "node:fs";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const PKG = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
15
+
16
+ const COMMENT = /^\s*(?:\/\/|\/\*|\*|#)/;
17
+
18
+ // ---- status palette discipline ----
19
+
20
+ /**
21
+ * The status palette's one Tailwind family: emerald/amber/sky, across every
22
+ * prefix that can carry a colour. `Badge` and `statusTone.ts` are its one
23
+ * home — see `statusTone.ts`'s own docstring — so a third place naming the
24
+ * family is a fork of the palette, not a use of it.
25
+ */
26
+ const STATUS_PALETTE =
27
+ /\b(?:bg|text|border|ring|fill|stroke|from|to|via|outline|decoration|shadow)-(?:emerald|amber|sky)-\d/;
28
+
29
+ /** Every `file:line` in `text` painting with the status palette, outside a comment. */
30
+ export function statusPaletteIn(file: string, text: string): string[] {
31
+ return text
32
+ .split("\n")
33
+ .map((line, index) => ({ line, at: index + 1 }))
34
+ .filter(({ line }) => !COMMENT.test(line) && STATUS_PALETTE.test(line))
35
+ .map(({ line, at }) => `${file}:${at}: ${line.trim()}`);
36
+ }
37
+
38
+ /**
39
+ * A colour family that competes with the status palette for the same job —
40
+ * "a warning", "a success" — and so could stand in for it undetected. Unlike
41
+ * the palette itself these have no allowed home anywhere in a consumer.
42
+ */
43
+ const COMPETING_PALETTE = /\b(?:bg|text|border)-(?:green|lime|teal|yellow|orange|blue|cyan|red)-\d/;
44
+
45
+ /** Every `file:line` in `text` reaching for a colour family that competes with the status palette. */
46
+ export function competingStatusPaletteIn(file: string, text: string): string[] {
47
+ return text
48
+ .split("\n")
49
+ .map((line, index) => ({ line, at: index + 1 }))
50
+ .filter(({ line }) => !COMMENT.test(line) && COMPETING_PALETTE.test(line))
51
+ .map(({ line, at }) => `${file}:${at}: ${line.trim()}`);
52
+ }
53
+
54
+ // ---- colour discipline (from tests/scripts/design_tokens.test.mjs) ----
55
+
56
+ /**
57
+ * A Tailwind arbitrary value whose content is a colour.
58
+ *
59
+ * Assembled from fragments so this file does not match itself. The `-` before
60
+ * the bracket is what makes it a *utility* rather than an array index or a
61
+ * TypeScript tuple type.
62
+ */
63
+ const HEX = ["#", "[0-9a-fA-F]{3,8}"].join("");
64
+ const LITERAL = String.raw`(?:${HEX}|rgba?\(|hsla?\(|oklch\()`;
65
+ // A raw colour right inside the bracket…
66
+ const ARBITRARY_COLOUR = new RegExp(String.raw`-\[\s*(?:${LITERAL}|var\(\s*--)`);
67
+ // The one colour-mix the preset writes mixes tokens only:
68
+ // `color-mix(in_oklch,var(--secondary),var(--foreground)_5%)`. Any other
69
+ // argument shape — a literal, a named colour, a bare number — is a colour.
70
+ const TOKEN_MIX = String.raw`color-mix\(in_[a-z0-9-]+(?:,var\(--[a-z0-9-]+\)(?:_\d+(?:\.\d+)?%)?)+\)`;
71
+ const BRACKET_MIX = new RegExp(String.raw`-\[\s*color-mix\([^\]]*\]`);
72
+ const ALLOWED_MIX = new RegExp(String.raw`-\[\s*${TOKEN_MIX}\]`);
73
+
74
+ /** Every `file:line` in `text` that puts a colour inside a Tailwind class. */
75
+ export function colouredClassesIn(file: string, text: string): string[] {
76
+ return text
77
+ .split("\n")
78
+ .map((line, index) => ({ line, at: index + 1 }))
79
+ .filter(
80
+ ({ line }) =>
81
+ !COMMENT.test(line) &&
82
+ (ARBITRARY_COLOUR.test(line) || (BRACKET_MIX.test(line) && !ALLOWED_MIX.test(line))),
83
+ )
84
+ .map(({ line, at }) => `${file}:${at}: ${line.trim()}`);
85
+ }
86
+
87
+ /**
88
+ * `DESIGN.md` "Where the brand is": the brand is identity, not a functional-UI
89
+ * colour — the wordmark and the styleguide swatch that shows it off, nothing
90
+ * a person acts on. This is not a headcount: the gate does not exist to hold
91
+ * a count of sites, it exists so brand can never migrate onto a control (a
92
+ * button, a progress fill, anything with a function) instead of staying the
93
+ * one place it is allowed to just be seen. A pure function over one file's
94
+ * text, so the gate is provable with fabricated input, and `COMMENT` keeps
95
+ * the styles.css line that *states* the rule from counting as a usage of it.
96
+ */
97
+ const BRAND_UTILITY = /\b(?:bg|text|border|ring|fill|stroke)-brand\b/;
98
+
99
+ /** Every line in `text` that paints with the brand colour. */
100
+ export function brandUsagesIn(
101
+ file: string,
102
+ text: string,
103
+ ): { file: string; at: number; text: string }[] {
104
+ return text
105
+ .split("\n")
106
+ .map((line, index) => ({ line, at: index + 1 }))
107
+ .filter(({ line }) => !COMMENT.test(line) && BRAND_UTILITY.test(line))
108
+ .map(({ line, at }) => ({ file, at, text: line.trim() }));
109
+ }
110
+
111
+ // ---- stylesheet parsing (from src/theme/tokens.test.ts, translated TS→JS) ----
112
+
113
+ /** Whitespace is presentation; a value that wraps is the same value — used internally by `rawDeclarations`. */
114
+ function normalize(value: string): string {
115
+ return value.replace(/\s+/g, " ").trim();
116
+ }
117
+
118
+ /**
119
+ * The `{ … }` body belonging to the block whose header (e.g. `:root {` or
120
+ * `.dark {`) appears first in the file, matched by brace depth rather than by
121
+ * the next `\n}` so a nested `calc(...)` or `color-mix(...)` cannot fool it.
122
+ */
123
+ export function blockBody(css: string, header: string): string {
124
+ const headerAt = css.indexOf(header);
125
+ if (headerAt === -1) throw new Error(`stylesheet has no ${JSON.stringify(header)} block`);
126
+ const braceAt = css.indexOf("{", headerAt);
127
+ let depth = 1;
128
+ let i = braceAt + 1;
129
+ while (depth > 0 && i < css.length) {
130
+ if (css[i] === "{") depth++;
131
+ else if (css[i] === "}") depth--;
132
+ i++;
133
+ }
134
+ return css.slice(braceAt + 1, i - 1);
135
+ }
136
+
137
+ /** Every `--name: value;` declaration in a block, keyed WITH the leading `--`. */
138
+ export function rawDeclarations(block: string): Map<string, string> {
139
+ const map = new Map<string, string>();
140
+ for (const match of block.matchAll(/(--[\w-]+):\s*([^;]+);/g)) {
141
+ map.set(match[1], normalize(match[2]));
142
+ }
143
+ return map;
144
+ }
145
+
146
+ /** The same, keyed WITHOUT the leading `--` — the shape `LIGHT_THEME` uses. */
147
+ export function declarations(block: string): Map<string, string> {
148
+ const map = new Map<string, string>();
149
+ for (const [name, value] of rawDeclarations(block)) {
150
+ map.set(name.slice(2), value);
151
+ }
152
+ return map;
153
+ }
154
+
155
+ // ---- foundation facts ----
156
+
157
+ /**
158
+ * The foundation token names, read off the shipped stylesheet's `:root` so
159
+ * they can never drift from what actually runs. `radius` is a geometry
160
+ * setting, not a colour token, and is excluded — it matches LIGHT_THEME's keys.
161
+ */
162
+ export function foundationTokenNames(): string[] {
163
+ const css = readFileSync(path.join(PKG, "src/theme/styles.css"), "utf8");
164
+ return [...declarations(blockBody(css, ":root {")).keys()].filter((n) => n !== "radius");
165
+ }