refactor: split website and reusable UI package #32
86 changed files with 102 additions and 85 deletions
|
|
@ -2,7 +2,6 @@ import type { StorybookConfig } from "@storybook/react-vite";
|
|||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.stories.@(js|ts|tsx)"],
|
||||
staticDirs: ["../static"],
|
||||
addons: [
|
||||
"@storybook/addon-a11y",
|
||||
"@storybook/addon-vitest",
|
||||
|
|
@ -11,9 +10,6 @@ const config: StorybookConfig = {
|
|||
name: "@storybook/react-vite",
|
||||
options: {},
|
||||
},
|
||||
docs: {
|
||||
autodocs: "tag",
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -1,17 +1,5 @@
|
|||
import "../src/app.css";
|
||||
import "../src/styles.css";
|
||||
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 = {
|
||||
decorators: [
|
||||
|
|
@ -44,10 +32,10 @@ const preview: Preview = {
|
|||
backgrounds: {
|
||||
default: "canvas",
|
||||
values: [
|
||||
{ name: "canvas", value: "#020302" },
|
||||
{ name: "raised", value: "#0b0d0c" },
|
||||
{ name: "light canvas", value: "#f3f5ed" },
|
||||
{ name: "light raised", value: "#eef1e7" },
|
||||
{ name: "canvas", value: "#0b0f0d" },
|
||||
{ name: "raised", value: "#151d18" },
|
||||
{ name: "light canvas", value: "#eef2e7" },
|
||||
{ name: "light raised", value: "#f1f5ea" },
|
||||
],
|
||||
},
|
||||
controls: {
|
||||
74
packages/ui/src/content-boundary.test.ts
Normal file
74
packages/ui/src/content-boundary.test.ts
Normal 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");
|
||||
}
|
||||
|
|
@ -29,7 +29,6 @@ export { TelemetryStrip } from "./components/TelemetryStrip";
|
|||
export { ThemeToggle } from "./components/ThemeToggle";
|
||||
export { WeatherModule } from "./components/WeatherModule";
|
||||
export { dashboardPreviewFixtures } from "./fixtures";
|
||||
export { dashboardDocumentToUiDashboard } from "./model-renderer";
|
||||
export {
|
||||
getNextUiTheme,
|
||||
isUiTheme,
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "$lib/utils"
|
||||
import { cn } from "../utils"
|
||||
|
||||
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",
|
||||
|
|
@ -2,7 +2,7 @@ import * as React from "react"
|
|||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "$lib/utils"
|
||||
import { cn } from "../utils"
|
||||
|
||||
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!",
|
||||
|
|
@ -2,7 +2,7 @@ import * as React from "react"
|
|||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "$lib/utils"
|
||||
import { cn } from "../utils"
|
||||
|
||||
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",
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "$lib/utils"
|
||||
import { cn } from "../utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import * as React from "react"
|
||||
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "$lib/utils"
|
||||
import { cn } from "../utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "$lib/utils"
|
||||
import { cn } from "../utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { cn } from "$lib/utils"
|
||||
import { cn } from "../utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
|
|
@ -3,8 +3,11 @@ import { join } from "node:path";
|
|||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
const componentsDir = join(root, "src/lib/ui/components");
|
||||
const storiesDir = join(root, "src/lib/ui/stories");
|
||||
const packageRoot = existsSync(join(root, "packages/ui/package.json"))
|
||||
? join(root, "packages/ui")
|
||||
: root;
|
||||
const componentsDir = join(packageRoot, "src/components");
|
||||
const storiesDir = join(packageRoot, "src/stories");
|
||||
|
||||
const requiredStoryFiles = [
|
||||
"Badge.stories.tsx",
|
||||
|
|
@ -49,7 +52,7 @@ const forbiddenStoryContent = [
|
|||
|
||||
describe("Storybook inventory", () => {
|
||||
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>;
|
||||
};
|
||||
|
||||
|
|
@ -65,21 +68,22 @@ describe("Storybook inventory", () => {
|
|||
});
|
||||
|
||||
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(
|
||||
join(componentsDir, "DashboardFrame.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(appStyles).toContain('./lib/ui/components/styles.css');
|
||||
expect(packageStyles).toContain('./components/styles.css');
|
||||
expect(dashboardFrame).not.toContain('./styles.css');
|
||||
});
|
||||
|
||||
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("data-ui-theme");
|
||||
expect(previewSource).toContain("../src/styles.css");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
4
packages/ui/src/styles.css
Normal file
4
packages/ui/src/styles.css
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
@import "@fontsource-variable/geist";
|
||||
@import "uplot/dist/uPlot.min.css";
|
||||
@import "./tokens.css";
|
||||
@import "./components/styles.css";
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue