@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.
- package/README.md +15 -7
- package/dist/{primitives → components}/alert.js +1 -1
- package/dist/{primitives → components}/badge.js +1 -1
- package/dist/{primitives → components}/button.d.ts +1 -1
- package/dist/{primitives → components}/button.js +4 -1
- package/dist/{primitives → components}/card.d.ts +1 -1
- package/dist/{primitives → components}/card.js +2 -2
- package/dist/{primitives → components}/combobox.js +3 -3
- package/dist/{primitives → components}/dialog.js +1 -1
- package/dist/{primitives → components}/dropdown-menu.js +11 -6
- package/dist/{primitives → components}/field.js +2 -4
- package/dist/{primitives → components}/input-group.js +1 -1
- package/dist/{primitives → components}/input.js +1 -1
- package/dist/{primitives → components}/label.js +1 -1
- package/dist/components/progress.js +7 -0
- package/dist/{primitives → components}/select.d.ts +3 -1
- package/dist/{primitives → components}/select.js +6 -4
- package/dist/{primitives → components}/separator.js +1 -1
- package/dist/{primitives → components}/sheet.js +1 -1
- package/dist/{primitives → components}/skeleton.js +1 -1
- package/dist/{primitives → components}/sonner.js +17 -12
- package/dist/{primitives → components}/table.d.ts +1 -1
- package/dist/{primitives → components}/table.js +3 -3
- package/dist/{primitives → components}/tabs.js +1 -1
- package/dist/{primitives → components}/textarea.js +1 -1
- package/dist/{primitives → components}/tooltip.js +1 -1
- package/dist/gates/index.d.ts +28 -0
- package/dist/gates/index.js +142 -0
- package/dist/index.d.ts +31 -32
- package/dist/index.js +32 -33
- package/dist/{tokens.d.ts → theme/tokens.d.ts} +5 -5
- package/dist/{tokens.js → theme/tokens.js} +12 -12
- package/package.json +23 -12
- package/{shadcn → src/components}/alert.tsx +14 -21
- package/src/{primitives → components}/badge.tsx +15 -21
- package/src/{primitives → components}/button.tsx +13 -10
- package/{shadcn → src/components}/card.tsx +16 -31
- package/src/{primitives → components}/combobox.test.tsx +15 -2
- package/src/{primitives → components}/combobox.tsx +45 -74
- package/src/{primitives/primitives.test.tsx → components/components.test.tsx} +106 -69
- package/src/{primitives → components}/dialog.tsx +31 -54
- package/src/{primitives → components}/dropdown-menu.tsx +51 -65
- package/src/{primitives → components}/field.tsx +48 -62
- package/src/{primitives → components}/input-group.tsx +37 -49
- package/{shadcn → src/components}/input.tsx +5 -5
- package/{shadcn → src/components}/label.tsx +7 -10
- package/src/{primitives → components}/progress.tsx +7 -6
- package/{shadcn → src/components}/select.tsx +39 -41
- package/{shadcn → src/components}/separator.tsx +7 -7
- package/src/{primitives → components}/sheet.tsx +27 -44
- package/{shadcn → src/components}/skeleton.tsx +3 -3
- package/src/{primitives → components}/sonner.test.tsx +12 -4
- package/src/components/sonner.tsx +67 -0
- package/{shadcn → src/components}/table.tsx +17 -44
- package/{shadcn → src/components}/tabs.tsx +16 -26
- package/{shadcn → src/components}/textarea.tsx +5 -5
- package/{shadcn → src/components}/tooltip.tsx +12 -16
- package/src/gates/design.test.ts +188 -0
- package/src/gates/index.test.ts +11 -0
- package/src/gates/index.ts +165 -0
- package/src/gates/tokens.test.ts +215 -0
- package/src/harness.test.tsx +1 -1
- package/src/index.ts +63 -33
- package/src/theme/imports.test.ts +160 -0
- package/src/{statusTone.test.ts → theme/statusTone.test.ts} +5 -1
- package/src/{styles.css → theme/styles.css} +45 -31
- package/src/theme/tailwind.css +646 -0
- package/src/{tokens.test.ts → theme/tokens.test.ts} +30 -23
- package/src/{tokens.ts → theme/tokens.ts} +12 -12
- package/components.json +0 -13
- package/dist/lib/button.d.ts +0 -1
- package/dist/lib/button.js +0 -4
- package/dist/lib/cn.d.ts +0 -18
- package/dist/lib/cn.js +0 -21
- package/dist/lib/menu.d.ts +0 -1
- package/dist/lib/menu.js +0 -16
- package/dist/lib/progress.d.ts +0 -3
- package/dist/lib/progress.js +0 -6
- package/dist/lib/select.d.ts +0 -1
- package/dist/lib/select.js +0 -3
- package/dist/primitives/progress.js +0 -7
- package/gates/canonical.test.mjs +0 -71
- package/gates/extensions.test.mjs +0 -373
- package/gates/index.d.mts +0 -51
- package/gates/index.mjs +0 -514
- package/gates/tokens.test.mjs +0 -276
- package/shadcn/README.md +0 -13
- package/shadcn/badge.tsx +0 -49
- package/shadcn/button.tsx +0 -67
- package/shadcn/combobox.tsx +0 -299
- package/shadcn/dialog.tsx +0 -166
- package/shadcn/dropdown-menu.tsx +0 -267
- package/shadcn/field.tsx +0 -236
- package/shadcn/input-group.tsx +0 -154
- package/shadcn/progress.tsx +0 -29
- package/shadcn/sheet.tsx +0 -145
- package/shadcn/sonner.tsx +0 -49
- package/src/lib/button.ts +0 -4
- package/src/lib/cn.test.ts +0 -33
- package/src/lib/cn.ts +0 -23
- package/src/lib/menu.ts +0 -16
- package/src/lib/progress.ts +0 -6
- package/src/lib/select.ts +0 -4
- package/src/primitives/alert.tsx +0 -76
- package/src/primitives/card.tsx +0 -103
- package/src/primitives/input.tsx +0 -19
- package/src/primitives/label.tsx +0 -22
- package/src/primitives/select.tsx +0 -192
- package/src/primitives/separator.tsx +0 -28
- package/src/primitives/skeleton.tsx +0 -13
- package/src/primitives/sonner.tsx +0 -65
- package/src/primitives/table.tsx +0 -114
- package/src/primitives/tabs.tsx +0 -88
- package/src/primitives/textarea.tsx +0 -18
- package/src/primitives/tooltip.tsx +0 -57
- /package/dist/{primitives → components}/alert.d.ts +0 -0
- /package/dist/{primitives → components}/badge.d.ts +0 -0
- /package/dist/{primitives → components}/combobox.d.ts +0 -0
- /package/dist/{primitives → components}/dialog.d.ts +0 -0
- /package/dist/{primitives → components}/dropdown-menu.d.ts +0 -0
- /package/dist/{primitives → components}/field.d.ts +0 -0
- /package/dist/{primitives → components}/input-group.d.ts +0 -0
- /package/dist/{primitives → components}/input.d.ts +0 -0
- /package/dist/{primitives → components}/label.d.ts +0 -0
- /package/dist/{primitives → components}/progress.d.ts +0 -0
- /package/dist/{primitives → components}/separator.d.ts +0 -0
- /package/dist/{primitives → components}/sheet.d.ts +0 -0
- /package/dist/{primitives → components}/skeleton.d.ts +0 -0
- /package/dist/{primitives → components}/sonner.d.ts +0 -0
- /package/dist/{primitives → components}/tabs.d.ts +0 -0
- /package/dist/{primitives → components}/textarea.d.ts +0 -0
- /package/dist/{primitives → components}/tooltip.d.ts +0 -0
- /package/dist/{statusTone.d.ts → theme/statusTone.d.ts} +0 -0
- /package/dist/{statusTone.js → theme/statusTone.js} +0 -0
- /package/src/{statusTone.ts → theme/statusTone.ts} +0 -0
|
@@ -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
|
+
});
|
package/src/harness.test.tsx
CHANGED
|
@@ -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 "./
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
15
|
-
*
|
|
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 {
|
|
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
|
-
//
|
|
31
|
-
export { Button, buttonVariants } from "./
|
|
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 "./
|
|
41
|
-
export { Input } from "./
|
|
42
|
-
export { Textarea } from "./
|
|
43
|
-
export { Label } from "./
|
|
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 "./
|
|
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 "./
|
|
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 "./
|
|
82
|
-
export { Badge, badgeVariants } from "./
|
|
83
|
-
export { Alert, AlertAction, AlertDescription, AlertTitle } from "./
|
|
84
|
-
export {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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 "./
|
|
105
|
-
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./
|
|
106
|
-
export { Progress } from "./
|
|
107
|
-
export {
|
|
108
|
-
export {
|
|
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 "./
|
|
149
|
+
} from "./components/table.js";
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
/**
|
|
3
|
+
* Everything `styles.css` imports really resolves — packages and siblings both.
|
|
4
|
+
*
|
|
5
|
+
* The stylesheet ships as **source**: `exports["./styles.css"]` hands a consumer
|
|
6
|
+
* the file itself, and the consumer's Tailwind build is what resolves the
|
|
7
|
+
* `@import`s in it. So a package named there is a genuine runtime dependency of
|
|
8
|
+
* this one, and a sibling named there is a file that has to be published. Get
|
|
9
|
+
* either wrong and every consumer breaks while `pnpm lint`, `pnpm build` and
|
|
10
|
+
* `pnpm test` all stay green — nothing in this repository compiles CSS, so
|
|
11
|
+
* nothing here would otherwise notice. A rule nothing checks is a preference;
|
|
12
|
+
* this is the check.
|
|
13
|
+
*
|
|
14
|
+
* Both halves matter, and they fail differently. A missing **package** is a
|
|
15
|
+
* dependency that was dropped from `package.json` because a grep for
|
|
16
|
+
* `from "..."` never sees a CSS `@import`. A missing **sibling** is a file that
|
|
17
|
+
* exists on the author's disk but was never committed, or was left out of
|
|
18
|
+
* `files` — so it resolves here and nowhere else.
|
|
19
|
+
*
|
|
20
|
+
* Package resolution goes through the package's own `exports` map with the
|
|
21
|
+
* `style` condition first, because that is the condition a CSS bundler asks for
|
|
22
|
+
* and some packages expose their stylesheet under no other — Node's own
|
|
23
|
+
* `import.meta.resolve` asks for `import`/`require` and would report a
|
|
24
|
+
* correctly installed package as missing.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { spawnSync } from "node:child_process";
|
|
28
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
|
|
32
|
+
import { expect, test } from "vitest";
|
|
33
|
+
|
|
34
|
+
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
35
|
+
const MODULES = path.join(REPO, "node_modules");
|
|
36
|
+
const STYLESHEET_DIR = path.join(REPO, "src/theme");
|
|
37
|
+
const STYLESHEET = readFileSync(path.join(STYLESHEET_DIR, "styles.css"), "utf8");
|
|
38
|
+
|
|
39
|
+
/** The conditions a CSS bundler offers, most specific first. */
|
|
40
|
+
const CONDITIONS = ["style", "default", "import", "require"];
|
|
41
|
+
|
|
42
|
+
/** Every `@import` specifier in the stylesheet, in source order. */
|
|
43
|
+
function importsIn(css: string): string[] {
|
|
44
|
+
return [...css.matchAll(/^\s*@import\s+["']([^"']+)["']/gm)].map((match) => match[1]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A URL needs no filesystem, so it is not this gate's business. */
|
|
48
|
+
const isRemote = (spec: string) => /^https?:/.test(spec);
|
|
49
|
+
|
|
50
|
+
/** `"./tailwind.css"`, `"../a/b.css"` — resolved against the stylesheet itself. */
|
|
51
|
+
const isRelative = (spec: string) => /^\.{1,2}\//.test(spec);
|
|
52
|
+
|
|
53
|
+
/** `"@scope/pkg/a/b"` → `["@scope/pkg", "./a/b"]`; `"pkg"` → `["pkg", "."]`. */
|
|
54
|
+
function splitSpecifier(spec: string): [name: string, subpath: string] {
|
|
55
|
+
const segments = spec.split("/");
|
|
56
|
+
const name = spec.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0];
|
|
57
|
+
const rest = spec.slice(name.length);
|
|
58
|
+
return [name, rest === "" ? "." : `.${rest}`];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The first branch of a conditional export any of `CONDITIONS` selects. */
|
|
62
|
+
function pickCondition(node: unknown): string | null {
|
|
63
|
+
if (typeof node === "string") return node;
|
|
64
|
+
if (node === null || typeof node !== "object") return null;
|
|
65
|
+
const branches = node as Record<string, unknown>;
|
|
66
|
+
for (const condition of CONDITIONS) {
|
|
67
|
+
if (condition in branches) {
|
|
68
|
+
const picked = pickCondition(branches[condition]);
|
|
69
|
+
if (picked !== null) return picked;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Where a package's `exports` (or its legacy fields) send one subpath. */
|
|
76
|
+
function targetOf(manifest: Record<string, unknown>, subpath: string): string | null {
|
|
77
|
+
const map = manifest.exports;
|
|
78
|
+
if (map === undefined) {
|
|
79
|
+
// No exports map: "." is the package's own stylesheet field, and any other
|
|
80
|
+
// subpath is a plain path inside the package.
|
|
81
|
+
if (subpath !== ".") return subpath;
|
|
82
|
+
return (manifest.style as string) ?? (manifest.main as string) ?? null;
|
|
83
|
+
}
|
|
84
|
+
if (typeof map === "string") return subpath === "." ? map : null;
|
|
85
|
+
const entries = map as Record<string, unknown>;
|
|
86
|
+
if (subpath in entries) return pickCondition(entries[subpath]);
|
|
87
|
+
// An exports map that is conditions all the way down is shorthand for ".".
|
|
88
|
+
if (subpath === "." && !Object.keys(entries).some((key) => key.startsWith("."))) {
|
|
89
|
+
return pickCondition(entries);
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The file a specifier lands on, or why it lands nowhere. */
|
|
95
|
+
function resolveStyleImport(spec: string): { file: string } | { reason: string } {
|
|
96
|
+
const [name, subpath] = splitSpecifier(spec);
|
|
97
|
+
const root = path.join(MODULES, ...name.split("/"));
|
|
98
|
+
const manifestPath = path.join(root, "package.json");
|
|
99
|
+
if (!existsSync(manifestPath)) {
|
|
100
|
+
return { reason: `${name} is not installed — it is missing from package.json dependencies` };
|
|
101
|
+
}
|
|
102
|
+
const target = targetOf(JSON.parse(readFileSync(manifestPath, "utf8")), subpath);
|
|
103
|
+
if (target === null) {
|
|
104
|
+
return { reason: `${name} declares no export for ${subpath}` };
|
|
105
|
+
}
|
|
106
|
+
const file = path.join(root, target);
|
|
107
|
+
if (!existsSync(file)) {
|
|
108
|
+
return { reason: `${name} exports ${subpath} as ${target}, which does not exist` };
|
|
109
|
+
}
|
|
110
|
+
return { file };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
test("every package the stylesheet imports is installed and exports the file it names", () => {
|
|
114
|
+
const specs = importsIn(STYLESHEET).filter((spec) => !isRemote(spec) && !isRelative(spec));
|
|
115
|
+
expect(specs.length > 0, "the stylesheet named no packages, so this proves nothing").toBe(true);
|
|
116
|
+
|
|
117
|
+
const broken = specs
|
|
118
|
+
.map((spec) => ({ spec, result: resolveStyleImport(spec) }))
|
|
119
|
+
.filter((entry) => "reason" in entry.result)
|
|
120
|
+
.map((entry) => `@import "${entry.spec}": ${(entry.result as { reason: string }).reason}`);
|
|
121
|
+
|
|
122
|
+
expect(
|
|
123
|
+
broken,
|
|
124
|
+
"styles.css ships as source and the consumer's Tailwind build resolves these, so an " +
|
|
125
|
+
"unresolvable @import breaks every consumer and nothing in this repository compiles " +
|
|
126
|
+
`CSS to catch it:\n${broken.join("\n")}`,
|
|
127
|
+
).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("every sibling the stylesheet imports exists and is committed", () => {
|
|
131
|
+
const specs = importsIn(STYLESHEET).filter(isRelative);
|
|
132
|
+
expect(specs.length > 0, "the stylesheet named no siblings, so this proves nothing").toBe(true);
|
|
133
|
+
|
|
134
|
+
const broken = specs.flatMap((spec) => {
|
|
135
|
+
const file = path.resolve(STYLESHEET_DIR, spec);
|
|
136
|
+
if (!existsSync(file)) {
|
|
137
|
+
return [`@import "${spec}": ${path.relative(REPO, file)} does not exist`];
|
|
138
|
+
}
|
|
139
|
+
// On disk is not enough. `files: ["src"]` publishes what the working tree
|
|
140
|
+
// holds, so an uncommitted sibling packs fine here and is absent from the
|
|
141
|
+
// clone CI builds and publishes from — the failure lands on consumers only.
|
|
142
|
+
const tracked = spawnSync("git", ["ls-files", "--error-unmatch", file], {
|
|
143
|
+
cwd: REPO,
|
|
144
|
+
encoding: "utf8",
|
|
145
|
+
});
|
|
146
|
+
if (tracked.status !== 0) {
|
|
147
|
+
return [
|
|
148
|
+
`@import "${spec}": ${path.relative(REPO, file)} exists but is not tracked by git — ` +
|
|
149
|
+
"commit it, or the published package will not carry it",
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
return [];
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(
|
|
156
|
+
broken,
|
|
157
|
+
"styles.css ships as source, so a sibling it imports has to reach the consumer with it:\n" +
|
|
158
|
+
broken.join("\n"),
|
|
159
|
+
).toEqual([]);
|
|
160
|
+
});
|
|
@@ -13,7 +13,11 @@ describe("status tones", () => {
|
|
|
13
13
|
expect(STATUS_INK.warning).toBe("text-amber-700 dark:text-amber-400");
|
|
14
14
|
expect(STATUS_INK.success).toBe("text-emerald-700 dark:text-emerald-400");
|
|
15
15
|
expect(STATUS_INK.info).toBe("text-sky-700 dark:text-sky-400");
|
|
16
|
-
for (const v of [
|
|
16
|
+
for (const v of [
|
|
17
|
+
...Object.values(TONE_FILL),
|
|
18
|
+
...Object.values(TONE_BORDER),
|
|
19
|
+
...Object.values(STATUS_INK),
|
|
20
|
+
]) {
|
|
17
21
|
expect(v).not.toMatch(/\b(?:bg|border|text)-(?:success|warning)\b/);
|
|
18
22
|
}
|
|
19
23
|
});
|