@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
|
@@ -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 "
|
|
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 "
|
|
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 "
|
|
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
|
+
}
|