@robomous/ui-core 0.1.0 → 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 +19 -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 +4 -4
  9. package/dist/{primitives → components}/dialog.js +1 -1
  10. package/dist/{primitives → components}/dropdown-menu.js +12 -7
  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 +7 -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 +40 -40
  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 -190
  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
@@ -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
+ }
@@ -0,0 +1,215 @@
1
+ // @vitest-environment node
2
+ /**
3
+ * `DESIGN.md`'s first principle, machine-enforced: **never a colour in a class
4
+ * string.** The helpers live in `./index.ts` so consumers run the same scans;
5
+ * this file keeps their fabricated-input self-tests and runs the repo gates
6
+ * over this package's own sources.
7
+ */
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import path from "node:path";
11
+ import { spawnSync } from "node:child_process";
12
+ import { fileURLToPath } from "node:url";
13
+ import { expect, test } from "vitest";
14
+
15
+ import { brandUsagesIn, colouredClassesIn } from "./index.js";
16
+
17
+ const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
18
+
19
+ const SOURCE = /\.(?:ts|tsx|css)$/;
20
+
21
+ // Fragment for fabricating violating input without this file matching itself.
22
+ const HEX = ["#", "[0-9a-fA-F]{3,8}"].join("");
23
+
24
+ /**
25
+ * Assembles a bracketed Tailwind arbitrary-value utility (e.g.
26
+ * `text-[var(--accent)]`) from its prefix and bracket contents at runtime, for
27
+ * the same reason `HEX` above is built from fragments: this file scans its
28
+ * own package, so a fixture spelled out contiguously would trip the very gate
29
+ * it exists to exercise.
30
+ */
31
+ const bracket = (prefix: string, inner: string) => `${prefix}-[${inner}]`;
32
+
33
+ /**
34
+ * Assembles a `<prefix>-brand<suffix>` utility fixture at runtime, for the
35
+ * same self-matching reason as `bracket` above.
36
+ */
37
+ const brand = (prefix: string, suffix = "") => `${prefix}-brand${suffix}`;
38
+
39
+ test("the scan finds a colour smuggled into a class, and nothing that merely looks like one", () => {
40
+ expect(colouredClassesIn("a.tsx", ` <div className="bg-[${HEX.slice(0, 1)}eb5a47]" />`)).toEqual(
41
+ [`a.tsx:1: <div className="bg-[${HEX.slice(0, 1)}eb5a47]" />`],
42
+ );
43
+ expect(colouredClassesIn("b.tsx", ` className="${bracket("text", "var(--accent)")}"`)).toEqual([
44
+ `b.tsx:1: className="${bracket("text", "var(--accent)")}"`,
45
+ ]);
46
+ expect(colouredClassesIn("c.tsx", ` className="${bracket("ring", "rgb(0 0 0)")}"`)).toEqual([
47
+ `c.tsx:1: className="${bracket("ring", "rgb(0 0 0)")}"`,
48
+ ]);
49
+
50
+ // A token utility is the whole point of the rule and must pass.
51
+ expect(colouredClassesIn("d.tsx", ` className="bg-primary text-primary-foreground"`)).toEqual(
52
+ [],
53
+ );
54
+ // The accent at 10% is a token with an opacity modifier, not a colour.
55
+ expect(colouredClassesIn("e.tsx", ` className="bg-primary/10 border-primary"`)).toEqual([]);
56
+ // An arbitrary value that is *not* a colour stays legal — the rule is about
57
+ // colour, and a one-off `top-[50%]` is not what v1 got wrong.
58
+ expect(colouredClassesIn("f.tsx", ` className="translate-y-[3px]"`)).toEqual([]);
59
+ // An inline style carrying a schema-supplied colour is the sanctioned road:
60
+ // `classColor` answers with whatever the kernel stored, and Tailwind has never
61
+ // seen it, so no utility could name it.
62
+ expect(
63
+ colouredClassesIn("g.tsx", ` style={{ background: classColor(declared, name) }}`),
64
+ ).toEqual([]);
65
+ // A docstring explaining the rule must pass, or the gate forbids its own
66
+ // explanation — the mistake a boundary scan makes when it matches its own prose.
67
+ expect(colouredClassesIn("h.tsx", ` * Never write \`bg-[${"#"}eb5a47]\`.`)).toEqual([]);
68
+ // And a CSS custom property *declaration* is where colours are supposed to live.
69
+ expect(colouredClassesIn("i.css", ` --color-primary: #eb5a47;`)).toEqual([]);
70
+ // A colour-mix of two tokens names no colour of its own — the preset's own
71
+ // Button hover step.
72
+ expect(
73
+ colouredClassesIn(
74
+ "x.tsx",
75
+ 'className="hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]"',
76
+ ),
77
+ ).toEqual([]);
78
+ // A colour-mix that mixes in a literal is still a colour smuggled into a class.
79
+ expect(
80
+ colouredClassesIn("x.tsx", `className="${bracket("bg", "color-mix(in_srgb,#fff,var(--x))")}"`)
81
+ .length,
82
+ ).toBe(1);
83
+ // A named CSS colour inside color-mix is still a colour.
84
+ expect(
85
+ colouredClassesIn("x.tsx", `className="${bracket("bg", "color-mix(in_oklch,red,var(--x))")}"`)
86
+ .length,
87
+ ).toBe(1);
88
+ // Whatever order the tokens come in, and whichever colour space, two tokens
89
+ // stay two tokens.
90
+ expect(
91
+ colouredClassesIn("x.tsx", 'className="bg-[color-mix(in_srgb,var(--a)_40%,var(--b))]"').length,
92
+ ).toBe(0);
93
+ });
94
+
95
+ test("the brand scan counts a usage, and not the comment that states the rule", () => {
96
+ // A utility usage on any of the six colour-bearing prefixes is counted.
97
+ expect(brandUsagesIn("a.tsx", ` <span className="${brand("text")}">Robomous</span>`)).toEqual([
98
+ { file: "a.tsx", at: 1, text: `<span className="${brand("text")}">Robomous</span>` },
99
+ ]);
100
+ expect(
101
+ brandUsagesIn("b.tsx", ` className="h-full ${brand("bg")} transition-transform"`),
102
+ ).toEqual([
103
+ { file: "b.tsx", at: 1, text: `className="h-full ${brand("bg")} transition-transform"` },
104
+ ]);
105
+ // An opacity modifier is still a usage of the brand colour.
106
+ expect(brandUsagesIn("c.tsx", ` className="${brand("bg", "/10")}"`).map((u) => u.at)).toEqual([
107
+ 1,
108
+ ]);
109
+ // A comment line states the rule rather than applying it.
110
+ expect(brandUsagesIn("d.css", ` * a third \`${brand("bg")}\` is a design decision`)).toEqual(
111
+ [],
112
+ );
113
+ expect(brandUsagesIn("e.tsx", ` // never add ${brand("bg")} here`)).toEqual([]);
114
+ // Another token on the same prefixes is not the brand.
115
+ expect(brandUsagesIn("f.tsx", ` className="bg-primary text-primary-foreground"`)).toEqual([]);
116
+ // The token *name* without a utility prefix is not a usage — tokens.test.ts
117
+ // asserts LIGHT_THEME.brand's value and must not trip the gate.
118
+ expect(brandUsagesIn("g.ts", ` expect(COLOR.brand).toBe("#e85d44");`)).toEqual([]);
119
+ });
120
+
121
+ /** Every tracked source in this repo. */
122
+ function repoSources(): string[] {
123
+ const listed = spawnSync("git", ["ls-files", "-z"], { cwd: REPO, encoding: "utf8" });
124
+ expect(listed.status, `git ls-files failed: ${listed.stderr}`).toBe(0);
125
+ return listed.stdout.split("\0").filter((name) => SOURCE.test(name));
126
+ }
127
+
128
+ test("no source puts a colour inside a class name", () => {
129
+ const tracked = repoSources();
130
+ expect(tracked.length > 0, "the scan found no sources, so it proves nothing").toBeTruthy();
131
+
132
+ const offenders = tracked.flatMap((file) =>
133
+ colouredClassesIn(file, readFileSync(path.join(REPO, file), "utf8")),
134
+ );
135
+ expect(
136
+ offenders,
137
+ "colour belongs to the token contract — add a token to " +
138
+ `src/theme/styles.css and name the intent:\n${offenders.join("\n")}`,
139
+ ).toEqual([]);
140
+ });
141
+
142
+ /**
143
+ * This package has zero brand sites: it declares the `--brand` token and never
144
+ * paints with it. `DESIGN.md` "Where the brand is": brand sites are a consumer
145
+ * decision — two enumerated sites per consuming app (the wordmark and the
146
+ * styleguide swatch that displays the value), gated in the consumer's repo.
147
+ */
148
+ const BRAND_SITES: string[] = [];
149
+
150
+ test("the brand colour paints nothing here — brand sites are a consumer decision", () => {
151
+ const tracked = repoSources();
152
+ expect(tracked.length > 0, "the scan found no sources, so it proves nothing").toBeTruthy();
153
+
154
+ const usages = tracked.flatMap((file) =>
155
+ brandUsagesIn(file, readFileSync(path.join(REPO, file), "utf8")),
156
+ );
157
+ expect(
158
+ usages.map((u) => u.file).sort(),
159
+ "DESIGN.md 'Where the brand is': this package declares the brand token and never uses it — " +
160
+ "a brand-coloured site belongs to a consuming app (two enumerated sites per app):\n" +
161
+ usages.map((u) => `${u.file}:${u.at}: ${u.text}`).join("\n"),
162
+ ).toEqual(BRAND_SITES);
163
+ });
164
+
165
+ /**
166
+ * Lucide is the icon set, and the only one.
167
+ *
168
+ * The rule is "one icon library", not "this particular library". So this
169
+ * guards whichever set is currently *not* in use, and the value below is the
170
+ * whole of what changes when that decision changes.
171
+ *
172
+ * Assembled from fragments so this file never holds the package's name as a
173
+ * contiguous string, and a repository-wide sweep for it never mistakes its own
174
+ * guard for a lingering usage.
175
+ */
176
+ const RETIRED_ICON_PACKAGE = ["@tabler", "icons-react"].join("/");
177
+
178
+ test("no package declares a second icon set, and no source imports one", () => {
179
+ const listed = spawnSync("git", ["ls-files", "-z"], { cwd: REPO, encoding: "utf8" });
180
+ expect(listed.status, `git ls-files failed: ${listed.stderr}`).toBe(0);
181
+ const tracked = listed.stdout.split("\0").filter(Boolean);
182
+
183
+ const manifests = tracked.filter((name) => /(?:^|\/)package\.json$/.test(name));
184
+ expect(manifests.length > 0, "no manifests were read, so this proves nothing").toBeTruthy();
185
+ const declaring = manifests.filter((name) =>
186
+ readFileSync(path.join(REPO, name), "utf8").includes(`"${RETIRED_ICON_PACKAGE}"`),
187
+ );
188
+ expect(
189
+ declaring,
190
+ `this package draws one icon set, and ${RETIRED_ICON_PACKAGE} is not it. ` +
191
+ `A second one is a decision for DESIGN.md, not a dependency:\n${declaring.join("\n")}`,
192
+ ).toEqual([]);
193
+
194
+ const sources = tracked.filter((name) => SOURCE.test(name));
195
+ expect(sources.length > 0, "no sources were read, so this proves nothing").toBeTruthy();
196
+ const importing = sources.filter((name) =>
197
+ new RegExp(String.raw`(?:from|require\()\s*["']${RETIRED_ICON_PACKAGE}["']`).test(
198
+ readFileSync(path.join(REPO, name), "utf8"),
199
+ ),
200
+ );
201
+ expect(importing, `these draw from the retired icon set:\n${importing.join("\n")}`).toEqual([]);
202
+ });
203
+
204
+ test("the tokens have exactly one home, and it is the stylesheet", () => {
205
+ const listed = spawnSync("git", ["ls-files", "-z"], { cwd: REPO, encoding: "utf8" });
206
+ expect(listed.status, `git ls-files failed: ${listed.stderr}`).toBe(0);
207
+ const configs = listed.stdout
208
+ .split("\0")
209
+ .filter((name) => /(?:^|\/)tailwind\.config\.[cm]?[jt]s$/.test(name));
210
+ expect(
211
+ configs,
212
+ "Tailwind v4 is CSS-first: the tokens live in src/theme/styles.css. " +
213
+ `A config file gives them a second definition that wins for some utilities and not others:\n${configs.join("\n")}`,
214
+ ).toEqual([]);
215
+ });
@@ -17,7 +17,7 @@
17
17
  import { render } from "@testing-library/react";
18
18
  import { expect, it, vi } from "vitest";
19
19
 
20
- import { Dialog, DialogContent, DialogTitle } from "./primitives/dialog";
20
+ import { Dialog, DialogContent, DialogTitle } from "./components/dialog";
21
21
 
22
22
  const unmountFocusReturned = vi.fn();
23
23
 
package/src/index.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * `@robomous/ui-core` — the Robomous design system.
3
3
  *
4
- * Extracted from Robomous/VisionSet. UI primitives: Radix behaviour under
5
- * shadcn Nova styling, iconed with lucide; the foundation design tokens; and
6
- * the status-tone vocabulary. A consumer imports exactly:
4
+ * Twenty-one React components this package owns outright, the design tokens
5
+ * they resolve through, and the status-tone vocabulary. A consumer imports
6
+ * exactly:
7
7
  *
8
8
  * ```ts
9
9
  * import "@robomous/ui-core/styles.css"; // once, in the app's entry
@@ -11,24 +11,22 @@
11
11
  * ```
12
12
  *
13
13
  * The public surface is listed explicitly rather than `export *`, so what this
14
- * package promises stays auditable. The gates that keep the primitives
15
- * canonical are published too: `import { ... } from "@robomous/ui-core/gates"`.
14
+ * package promises stays auditable. The scanners behind this repository's own
15
+ * design rules are published too, for a consumer to run over its own sources:
16
+ * `import { ... } from "@robomous/ui-core/gates"`. DESIGN.md names each rule
17
+ * and the gate that holds it.
16
18
  */
17
19
 
18
20
  // The design tokens, and their prose contract in DESIGN.md.
19
- export { cssVar, DARK_THEME, LIGHT_THEME, THEME } from "./tokens.js";
21
+ export { cssVar, DARK_THEME, LIGHT_THEME, THEME } from "./theme/tokens.js";
20
22
 
21
- export { inlineLink } from "./lib/button.js";
22
- export { cn } from "./lib/cn.js";
23
- export { menuSurface } from "./lib/menu.js";
24
- export { twoLineTrigger } from "./lib/select.js";
25
- export { progressAria } from "./lib/progress.js";
23
+ export { cn } from "cn";
26
24
 
27
25
  // The one home for status colour outside the Badge.
28
- export { STATUS_INK, TONE_BORDER, TONE_FILL, type StatusTone } from "./statusTone.js";
26
+ export { STATUS_INK, TONE_BORDER, TONE_FILL, type StatusTone } from "./theme/statusTone.js";
29
27
 
30
- // Primitives — Radix behaviour under shadcn Nova styling, iconed with lucide.
31
- export { Button, buttonVariants } from "./primitives/button.js";
28
+ // The components — Radix and Base UI behaviour, iconed with lucide.
29
+ export { Button, buttonVariants } from "./components/button.js";
32
30
  export {
33
31
  Card,
34
32
  CardAction,
@@ -37,10 +35,10 @@ export {
37
35
  CardFooter,
38
36
  CardHeader,
39
37
  CardTitle,
40
- } from "./primitives/card.js";
41
- export { Input } from "./primitives/input.js";
42
- export { Textarea } from "./primitives/textarea.js";
43
- export { Label } from "./primitives/label.js";
38
+ } from "./components/card.js";
39
+ export { Input } from "./components/input.js";
40
+ export { Textarea } from "./components/textarea.js";
41
+ export { Label } from "./components/label.js";
44
42
  export {
45
43
  Field,
46
44
  FieldContent,
@@ -52,7 +50,7 @@ export {
52
50
  FieldSeparator,
53
51
  FieldSet,
54
52
  FieldTitle,
55
- } from "./primitives/field.js";
53
+ } from "./components/field.js";
56
54
  export {
57
55
  InputGroup,
58
56
  InputGroupAddon,
@@ -60,7 +58,7 @@ export {
60
58
  InputGroupInput,
61
59
  InputGroupText,
62
60
  InputGroupTextarea,
63
- } from "./primitives/input-group.js";
61
+ } from "./components/input-group.js";
64
62
  export {
65
63
  Combobox,
66
64
  ComboboxChip,
@@ -78,13 +76,44 @@ export {
78
76
  ComboboxTrigger,
79
77
  ComboboxValue,
80
78
  useComboboxAnchor,
81
- } from "./primitives/combobox.js";
82
- export { Badge, badgeVariants } from "./primitives/badge.js";
83
- export { Alert, AlertAction, AlertDescription, AlertTitle } from "./primitives/alert.js";
84
- export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } from "./primitives/dialog.js";
85
- export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "./primitives/sheet.js";
86
- export { Tabs, TabsContent, TabsList, tabsListVariants, TabsTrigger } from "./primitives/tabs.js";
87
- export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue } from "./primitives/select.js";
79
+ } from "./components/combobox.js";
80
+ export { Badge, badgeVariants } from "./components/badge.js";
81
+ export { Alert, AlertAction, AlertDescription, AlertTitle } from "./components/alert.js";
82
+ export {
83
+ Dialog,
84
+ DialogClose,
85
+ DialogContent,
86
+ DialogDescription,
87
+ DialogFooter,
88
+ DialogHeader,
89
+ DialogOverlay,
90
+ DialogPortal,
91
+ DialogTitle,
92
+ DialogTrigger,
93
+ } from "./components/dialog.js";
94
+ export {
95
+ Sheet,
96
+ SheetClose,
97
+ SheetContent,
98
+ SheetDescription,
99
+ SheetFooter,
100
+ SheetHeader,
101
+ SheetTitle,
102
+ SheetTrigger,
103
+ } from "./components/sheet.js";
104
+ export { Tabs, TabsContent, TabsList, tabsListVariants, TabsTrigger } from "./components/tabs.js";
105
+ export {
106
+ Select,
107
+ SelectContent,
108
+ SelectGroup,
109
+ SelectItem,
110
+ SelectLabel,
111
+ SelectScrollDownButton,
112
+ SelectScrollUpButton,
113
+ SelectSeparator,
114
+ SelectTrigger,
115
+ SelectValue,
116
+ } from "./components/select.js";
88
117
  export {
89
118
  DropdownMenu,
90
119
  DropdownMenuCheckboxItem,
@@ -101,11 +130,12 @@ export {
101
130
  DropdownMenuSubContent,
102
131
  DropdownMenuSubTrigger,
103
132
  DropdownMenuTrigger,
104
- } from "./primitives/dropdown-menu.js";
105
- export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./primitives/tooltip.js";
106
- export { Progress } from "./primitives/progress.js";
107
- export { Skeleton } from "./primitives/skeleton.js";
108
- export { Toaster } from "./primitives/sonner.js";
133
+ } from "./components/dropdown-menu.js";
134
+ export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./components/tooltip.js";
135
+ export { Progress } from "./components/progress.js";
136
+ export { Separator } from "./components/separator.js";
137
+ export { Skeleton } from "./components/skeleton.js";
138
+ export { Toaster } from "./components/sonner.js";
109
139
  export { toast } from "sonner";
110
140
  export {
111
141
  Table,
@@ -116,4 +146,4 @@ export {
116
146
  TableHead,
117
147
  TableHeader,
118
148
  TableRow,
119
- } from "./primitives/table.js";
149
+ } from "./components/table.js";