refactor(ui): extract reusable component package

This commit is contained in:
vince 2026-06-20 05:27:09 +02:00
parent 87261b5a3f
commit 2664804e91
86 changed files with 102 additions and 85 deletions

View file

@ -2,7 +2,6 @@ import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ["../src/**/*.stories.@(js|ts|tsx)"], stories: ["../src/**/*.stories.@(js|ts|tsx)"],
staticDirs: ["../static"],
addons: [ addons: [
"@storybook/addon-a11y", "@storybook/addon-a11y",
"@storybook/addon-vitest", "@storybook/addon-vitest",
@ -11,9 +10,6 @@ const config: StorybookConfig = {
name: "@storybook/react-vite", name: "@storybook/react-vite",
options: {}, options: {},
}, },
docs: {
autodocs: "tag",
},
}; };
export default config; export default config;

View file

@ -1,17 +1,5 @@
import "../src/app.css"; import "../src/styles.css";
import type { Preview } from "@storybook/react-vite"; import type { Preview } from "@storybook/react-vite";
import { setupWorker } from "msw/browser";
import { externalApiHandlers } from "../src/lib/testing/external-api-mocks";
if (typeof window !== "undefined") {
const worker = setupWorker(...externalApiHandlers);
void worker.start({
onUnhandledRequest: "bypass",
serviceWorker: {
url: "/mockServiceWorker.js",
},
});
}
const preview: Preview = { const preview: Preview = {
decorators: [ decorators: [
@ -44,10 +32,10 @@ const preview: Preview = {
backgrounds: { backgrounds: {
default: "canvas", default: "canvas",
values: [ values: [
{ name: "canvas", value: "#020302" }, { name: "canvas", value: "#0b0f0d" },
{ name: "raised", value: "#0b0d0c" }, { name: "raised", value: "#151d18" },
{ name: "light canvas", value: "#f3f5ed" }, { name: "light canvas", value: "#eef2e7" },
{ name: "light raised", value: "#eef1e7" }, { name: "light raised", value: "#f1f5ea" },
], ],
}, },
controls: { controls: {

View file

@ -0,0 +1,74 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
const root = process.cwd();
const uiSourceRoot = existsSync(join(root, "packages/ui/src"))
? join(root, "packages/ui/src")
: join(root, "src");
const forbiddenTerms = [
"dimensionlab",
"dimension lab",
"vaultwarden",
"forgejo",
"grafana",
"uptime kuma",
"prometheus",
"backrest",
"open webui",
"comfyui",
"adminer",
"cockpit",
"ollama",
];
describe("UI package content boundary", () => {
test("contains the reusable dashboard component inventory", () => {
expect(existsSync(join(uiSourceRoot, "index.ts"))).toBe(true);
expect(existsSync(join(uiSourceRoot, "components/DashboardFrame.tsx"))).toBe(
true,
);
expect(existsSync(join(uiSourceRoot, "components/ThemeToggle.tsx"))).toBe(
true,
);
expect(existsSync(join(uiSourceRoot, "styles.css"))).toBe(true);
});
test("keeps environment-specific content out of reusable UI source", () => {
const source = readUiSource(uiSourceRoot).toLowerCase();
expect(forbiddenTerms.filter((term) => source.includes(term))).toEqual([]);
});
test("does not import website runtime modules", () => {
const source = readUiSource(uiSourceRoot);
expect(source).not.toMatch(
/from ["'](?:apps\/web|\$lib\/server|\$lib\/model)/,
);
expect(source).not.toContain("../web/");
});
test("keeps icon rendering driven by icon identifiers", () => {
const source = readUiSource(uiSourceRoot);
expect(source).not.toContain("@iconify-json/");
expect(source).not.toContain("/icons/");
});
});
function readUiSource(path: string): string {
if (!existsSync(path)) return "";
const stats = statSync(path);
if (stats.isFile()) {
if (path.endsWith(".test.ts") || path.endsWith(".test.tsx")) return "";
if (!/\.(tsx|ts|css)$/.test(path)) return "";
return readFileSync(path, "utf8");
}
return readdirSync(path)
.map((entry) => readUiSource(join(path, entry)))
.join("\n");
}

View file

@ -29,7 +29,6 @@ export { TelemetryStrip } from "./components/TelemetryStrip";
export { ThemeToggle } from "./components/ThemeToggle"; export { ThemeToggle } from "./components/ThemeToggle";
export { WeatherModule } from "./components/WeatherModule"; export { WeatherModule } from "./components/WeatherModule";
export { dashboardPreviewFixtures } from "./fixtures"; export { dashboardPreviewFixtures } from "./fixtures";
export { dashboardDocumentToUiDashboard } from "./model-renderer";
export { export {
getNextUiTheme, getNextUiTheme,
isUiTheme, isUiTheme,

View file

@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "$lib/utils" import { cn } from "../utils"
const alertVariants = cva( const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",

View file

@ -2,7 +2,7 @@ import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui" import { Slot } from "radix-ui"
import { cn } from "$lib/utils" import { cn } from "../utils"
const badgeVariants = cva( const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",

View file

@ -2,7 +2,7 @@ import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui" import { Slot } from "radix-ui"
import { cn } from "$lib/utils" import { cn } from "../utils"
const buttonVariants = cva( const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",

View file

@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react"
import { cn } from "$lib/utils" import { cn } from "../utils"
function Card({ function Card({
className, className,

View file

@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react"
import { Progress as ProgressPrimitive } from "radix-ui" import { Progress as ProgressPrimitive } from "radix-ui"
import { cn } from "$lib/utils" import { cn } from "../utils"
function Progress({ function Progress({
className, className,

View file

@ -3,7 +3,7 @@
import * as React from "react" import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui" import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "$lib/utils" import { cn } from "../utils"
function Separator({ function Separator({
className, className,

View file

@ -1,4 +1,4 @@
import { cn } from "$lib/utils" import { cn } from "../utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) { function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return ( return (

View file

@ -3,8 +3,11 @@ import { join } from "node:path";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
const root = process.cwd(); const root = process.cwd();
const componentsDir = join(root, "src/lib/ui/components"); const packageRoot = existsSync(join(root, "packages/ui/package.json"))
const storiesDir = join(root, "src/lib/ui/stories"); ? join(root, "packages/ui")
: root;
const componentsDir = join(packageRoot, "src/components");
const storiesDir = join(packageRoot, "src/stories");
const requiredStoryFiles = [ const requiredStoryFiles = [
"Badge.stories.tsx", "Badge.stories.tsx",
@ -49,7 +52,7 @@ const forbiddenStoryContent = [
describe("Storybook inventory", () => { describe("Storybook inventory", () => {
test("exposes scripts for local and static Storybook review", () => { test("exposes scripts for local and static Storybook review", () => {
const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as {
scripts?: Record<string, string>; scripts?: Record<string, string>;
}; };
@ -65,21 +68,22 @@ describe("Storybook inventory", () => {
}); });
test("loads dashboard component styles through the global app stylesheet", () => { test("loads dashboard component styles through the global app stylesheet", () => {
const appStyles = readFileSync(join(root, "src/app.css"), "utf8"); const packageStyles = readFileSync(join(packageRoot, "src/styles.css"), "utf8");
const dashboardFrame = readFileSync( const dashboardFrame = readFileSync(
join(componentsDir, "DashboardFrame.tsx"), join(componentsDir, "DashboardFrame.tsx"),
"utf8", "utf8",
); );
expect(appStyles).toContain('./lib/ui/components/styles.css'); expect(packageStyles).toContain('./components/styles.css');
expect(dashboardFrame).not.toContain('./styles.css'); expect(dashboardFrame).not.toContain('./styles.css');
}); });
test("configures Storybook theme switching for reusable components", () => { test("configures Storybook theme switching for reusable components", () => {
const previewSource = readFileSync(join(root, ".storybook/preview.ts"), "utf8"); const previewSource = readFileSync(join(packageRoot, ".storybook/preview.ts"), "utf8");
expect(previewSource).toContain("globalTypes"); expect(previewSource).toContain("globalTypes");
expect(previewSource).toContain("data-ui-theme"); expect(previewSource).toContain("data-ui-theme");
expect(previewSource).toContain("../src/styles.css");
}); });
test("keeps component and story files paired as the UI inventory changes", () => { test("keeps component and story files paired as the UI inventory changes", () => {
@ -135,7 +139,7 @@ describe("Storybook inventory", () => {
test("does not add deferred form/navigation primitives", () => { test("does not add deferred form/navigation primitives", () => {
for (const component of ["Input", "ToggleGroup", "ScrollArea"]) { for (const component of ["Input", "ToggleGroup", "ScrollArea"]) {
expect(existsSync(join(root, `src/lib/ui/components/${component}.tsx`))).toBe(false); expect(existsSync(join(componentsDir, `${component}.tsx`))).toBe(false);
expect(existsSync(join(storiesDir, `${component}.stories.tsx`))).toBe(false); expect(existsSync(join(storiesDir, `${component}.stories.tsx`))).toBe(false);
} }
}); });

View file

@ -0,0 +1,4 @@
@import "@fontsource-variable/geist";
@import "uplot/dist/uPlot.min.css";
@import "./tokens.css";
@import "./components/styles.css";

View file

@ -7,6 +7,7 @@
"outDir": "dist", "outDir": "dist",
"rootDir": "src" "rootDir": "src"
}, },
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": [ "exclude": [
"dist", "dist",
"node_modules", "node_modules",

View file

@ -1,49 +0,0 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
const forbiddenTerms = [
"dimensionlab",
"dimension lab",
"vaultwarden",
"forgejo",
"grafana",
"uptime kuma",
"prometheus",
"backrest",
"open webui",
"comfyui",
"adminer",
"cockpit",
"ollama",
];
describe("UI content boundary", () => {
test("keeps environment-specific content out of reusable UI source", () => {
const source = readUiSource(join(process.cwd(), "src", "lib", "ui"));
const normalized = source.toLowerCase();
expect(
forbiddenTerms.filter((term) => normalized.includes(term)),
).toEqual([]);
});
test("keeps icon rendering driven by icon identifiers", () => {
const source = readUiSource(join(process.cwd(), "src", "lib", "ui"));
expect(source).not.toContain("@iconify-json/");
expect(source).not.toContain("/icons/");
});
});
function readUiSource(path: string): string {
const stats = statSync(path);
if (stats.isFile()) {
if (path.endsWith(".test.ts")) return "";
return readFileSync(path, "utf8");
}
return readdirSync(path)
.map((entry) => readUiSource(join(path, entry)))
.join("\n");
}