diff --git a/.github/workflows/ui-test-vue.yml b/.github/workflows/ui-test-vue.yml
index 8b29f7fb68..869d69062b 100644
--- a/.github/workflows/ui-test-vue.yml
+++ b/.github/workflows/ui-test-vue.yml
@@ -86,3 +86,6 @@ jobs:
# fails if tokens.css has drifted from tokens.ts
bun run tokens:check
bun run test
+ # build the distributable and check its contract
+ bun run build
+ bun run verify:dist
diff --git a/plugins/ui/bun.lock b/plugins/ui/bun.lock
index 6db1ea1b67..5a3912803b 100644
--- a/plugins/ui/bun.lock
+++ b/plugins/ui/bun.lock
@@ -550,6 +550,7 @@
"vite": "^6.4.2",
"vitest": "^4.0.18",
"vue": "^3.5.17",
+ "vue-tsc": "^2.2.12",
"vuetify": "3.12.0",
},
"peerDependencies": {
diff --git a/plugins/ui/libs/d2e-ui/.gitignore b/plugins/ui/libs/d2e-ui/.gitignore
index 0c3679981e..eb16768191 100644
--- a/plugins/ui/libs/d2e-ui/.gitignore
+++ b/plugins/ui/libs/d2e-ui/.gitignore
@@ -1,2 +1,3 @@
node_modules/
.histoire/
+dist/
diff --git a/plugins/ui/libs/d2e-ui/README.md b/plugins/ui/libs/d2e-ui/README.md
index 108a441e5c..02be85da8d 100644
--- a/plugins/ui/libs/d2e-ui/README.md
+++ b/plugins/ui/libs/d2e-ui/README.md
@@ -1,12 +1,34 @@
# `@d2e/ui`
Vue 3 and Vuetify components for D2E, with the design tokens from the D2E
-design system. The application uses the source directly. There is no build
-step.
+design system. The application reads the source directly through vite aliases.
+Every other consumer uses the built artifact — see below.
+
+## Consuming the built package
+
+The application consumes this package as **source**, through aliases in
+`apps/vue-mri-ui-lib/vite.config*.ts`. Any other consumer should use the built
+artifact:
+
+```ts
+import { D2eButton, D2eDialog } from "@d2e/ui";
+import "@d2e/ui/tokens.css";
+import "@d2e/ui/style.css"; // component styles — required for the built package
+```
+
+`style.css` is new with the build. Scoped SFC styles used to be compiled into
+each consumer's own bundle; the built package emits them as one file instead.
+The application does not need it while it reads source.
+
+`vue` and `vuetify` are peer dependencies and are never bundled. Components
+import the Vuetify pieces they use, so a consumer does **not** need
+`vite-plugin-vuetify`.
## Commands
```bash
+bun run build # dist/index.js, dist/index.css, dist/types
+bun run verify:dist # checks exports and that peers stayed external
bun run test # unit tests
bun run tokens:build # write src/tokens/tokens.css from src/tokens/tokens.ts
bun run tokens:check # fail if tokens.css is not current
diff --git a/plugins/ui/libs/d2e-ui/package.json b/plugins/ui/libs/d2e-ui/package.json
index 6fd60fbc4c..78c1faafa8 100644
--- a/plugins/ui/libs/d2e-ui/package.json
+++ b/plugins/ui/libs/d2e-ui/package.json
@@ -5,17 +5,25 @@
"type": "module",
"description": "D2E Vue 3 + Vuetify component library",
"exports": {
- ".": "./src/index.ts",
+ ".": {
+ "types": "./dist/types/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./style.css": "./dist/index.css",
"./tokens.css": "./src/tokens/tokens.css"
},
"files": [
+ "dist",
"src"
],
"scripts": {
"tokens:build": "tsx scripts/build-tokens.ts",
"tokens:check": "tsx scripts/build-tokens.ts && git diff --exit-code src/tokens/tokens.css",
"test": "vitest run",
- "lint": "prettier --write ."
+ "lint": "prettier --write .",
+ "build": "vite build --config vite.config.lib.ts && bun run build:types",
+ "build:types": "vue-tsc --declaration --emitDeclarationOnly --outDir dist/types -p tsconfig.build.json",
+ "verify:dist": "node scripts/verify-dist.mjs"
},
"peerDependencies": {
"vue": "^3.5.0",
@@ -28,6 +36,10 @@
"vite": "^6.4.2",
"vitest": "^4.0.18",
"vue": "^3.5.17",
+ "vue-tsc": "^2.2.12",
"vuetify": "3.12.0"
- }
+ },
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/types/index.d.ts"
}
diff --git a/plugins/ui/libs/d2e-ui/scripts/verify-dist.mjs b/plugins/ui/libs/d2e-ui/scripts/verify-dist.mjs
new file mode 100644
index 0000000000..029d4f4e46
--- /dev/null
+++ b/plugins/ui/libs/d2e-ui/scripts/verify-dist.mjs
@@ -0,0 +1,99 @@
+// Verifies the built artifact without executing it. Plain node cannot import
+// dist/index.js: vuetify's ESM pulls in .css files, which only a bundler
+// resolves. These static checks catch the failures that actually matter —
+// a missing export, or a peer dependency accidentally bundled in.
+import { readFileSync, existsSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import path from "node:path";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const js = path.join(root, "dist/index.js");
+const css = path.join(root, "dist/index.css");
+const types = path.join(root, "dist/types/index.d.ts");
+
+const problems = [];
+
+for (const f of [js, css, types]) {
+ if (!existsSync(f))
+ problems.push(`missing artifact: ${path.relative(root, f)}`);
+}
+if (problems.length) {
+ console.error(problems.join("\n"));
+ process.exit(1);
+}
+
+const bundle = readFileSync(js, "utf8");
+
+const EXPECTED = [
+ "D2eButton",
+ "D2eCard",
+ "D2eDialog",
+ "D2eExplorationCard",
+ "D2eIconButton",
+ "D2eMenu",
+ "D2eStatusChip",
+ "D2eTextField",
+ "D2eToolbar",
+ "DIALOG_SIZE_MAP",
+ "EXPLORATION_STATUS_MAP",
+ "ICON_BUTTON_SIZE_MAP",
+ "SIZE_MAP",
+ "STATUS_CHIP_VARIANT_MAP",
+ "VARIANT_MAP",
+ "buildD2eVuetifyOptions",
+ "tokens",
+];
+// Pull the names out of the final `export { ... }` block.
+const exportBlock = bundle.match(/export\s*\{([\s\S]*?)\}/);
+if (!exportBlock) {
+ console.error("no export block found in dist/index.js");
+ process.exit(1);
+}
+const exported = new Set(
+ exportBlock[1]
+ .split(",")
+ .map((part) =>
+ part
+ .trim()
+ .split(/\s+as\s+/)
+ .pop(),
+ )
+ .filter(Boolean),
+);
+const missing = EXPECTED.filter((n) => !exported.has(n));
+if (missing.length) problems.push(`missing exports: ${missing.join(", ")}`);
+
+// vue and vuetify are peers; they must appear only as import specifiers.
+const specifiers = new Set(
+ [...bundle.matchAll(/from ?"([^"]+)"/g)].map((m) => m[1]),
+);
+const unexpected = [...specifiers].filter(
+ (s) => s !== "vue" && !s.startsWith("vuetify"),
+);
+if (unexpected.length)
+ problems.push(`unexpected runtime imports: ${unexpected.join(", ")}`);
+
+// Positive assertions. Checking only for *unexpected* specifiers misses the
+// case that matters: if a peer is bundled it stops appearing as an import at
+// all, so its absence is the symptom.
+if (!specifiers.has("vue"))
+ problems.push("vue is not imported — it may be bundled");
+if (![...specifiers].some((s) => s.startsWith("vuetify")))
+ problems.push("vuetify is not imported — it may be bundled");
+
+// Backstop: the library is small once the peers are external. Bundling
+// vuetify inflates it by an order of magnitude.
+const MAX_BYTES = 150_000;
+const size = readFileSync(js).length;
+if (size > MAX_BYTES)
+ problems.push(
+ `dist/index.js is ${size} bytes (limit ${MAX_BYTES}) — a peer is probably bundled`,
+ );
+
+if (problems.length) {
+ console.error(problems.join("\n"));
+ process.exit(1);
+}
+console.log(
+ `dist ok — ${EXPECTED.length} exports, peers external (${[...specifiers].join(", ")})`,
+);
diff --git a/plugins/ui/libs/d2e-ui/src/__tests__/d2e-button.test.ts b/plugins/ui/libs/d2e-ui/src/__tests__/d2e-button.test.ts
index 96f86d49aa..e3fa749fcd 100644
--- a/plugins/ui/libs/d2e-ui/src/__tests__/d2e-button.test.ts
+++ b/plugins/ui/libs/d2e-ui/src/__tests__/d2e-button.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { SIZE_MAP, VARIANT_MAP } from "../components/D2eButton.vue";
+import { SIZE_MAP, VARIANT_MAP } from "../components/buttonVariants";
describe("D2eButton lookup tables", () => {
it("maps every variant to the Vuetify variant/color pair", () => {
diff --git a/plugins/ui/libs/d2e-ui/src/__tests__/explorer-parity.test.ts b/plugins/ui/libs/d2e-ui/src/__tests__/explorer-parity.test.ts
index 260246cb2e..b61e7e6afe 100644
--- a/plugins/ui/libs/d2e-ui/src/__tests__/explorer-parity.test.ts
+++ b/plugins/ui/libs/d2e-ui/src/__tests__/explorer-parity.test.ts
@@ -19,7 +19,11 @@ const explorer = readManifest("explorer/package.json");
const app = readManifest("../../apps/vue-mri-ui-lib/package.json");
const minorOf = (range: string): string =>
- range.replace(/^[^\d]*/, "").split(".").slice(0, 2).join(".");
+ range
+ .replace(/^[^\d]*/, "")
+ .split(".")
+ .slice(0, 2)
+ .join(".");
describe("explorer stays outside the workspace", () => {
it("keeps the workspace globs one level deep", () => {
@@ -46,7 +50,7 @@ describe("explorer matches the application", () => {
it("uses the same vue minor version as the application", () => {
expect(minorOf(explorer.devDependencies.vue)).toBe(
- minorOf(app.dependencies.vue)
+ minorOf(app.dependencies.vue),
);
});
diff --git a/plugins/ui/libs/d2e-ui/src/__tests__/icon-button.test.ts b/plugins/ui/libs/d2e-ui/src/__tests__/icon-button.test.ts
index 5bf2df421b..2174eb6df4 100644
--- a/plugins/ui/libs/d2e-ui/src/__tests__/icon-button.test.ts
+++ b/plugins/ui/libs/d2e-ui/src/__tests__/icon-button.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { ICON_BUTTON_SIZE_MAP } from "../components/D2eIconButton.vue";
+import { ICON_BUTTON_SIZE_MAP } from "../components/iconButtonSizes";
describe("D2eIconButton size map", () => {
it("maps sizes to container and icon dimensions", () => {
diff --git a/plugins/ui/libs/d2e-ui/src/__tests__/status-chip.test.ts b/plugins/ui/libs/d2e-ui/src/__tests__/status-chip.test.ts
index 15130e5ecd..1ff06d2cd2 100644
--- a/plugins/ui/libs/d2e-ui/src/__tests__/status-chip.test.ts
+++ b/plugins/ui/libs/d2e-ui/src/__tests__/status-chip.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { STATUS_CHIP_VARIANT_MAP } from "../components/D2eStatusChip.vue";
+import { STATUS_CHIP_VARIANT_MAP } from "../components/statusChipVariants";
describe("D2eStatusChip variant map", () => {
it("maps every variant to the Figma background/text pair", () => {
diff --git a/plugins/ui/libs/d2e-ui/src/__tests__/tokens.test.ts b/plugins/ui/libs/d2e-ui/src/__tests__/tokens.test.ts
index 52bf14d5cd..b703665387 100644
--- a/plugins/ui/libs/d2e-ui/src/__tests__/tokens.test.ts
+++ b/plugins/ui/libs/d2e-ui/src/__tests__/tokens.test.ts
@@ -6,8 +6,8 @@ describe("tokens.css generator", () => {
const css = generateTokensCss();
expect(
css.startsWith(
- "/* GENERATED — DO NOT EDIT.\n * Source: src/tokens/tokens.ts."
- )
+ "/* GENERATED — DO NOT EDIT.\n * Source: src/tokens/tokens.ts.",
+ ),
).toBe(true);
});
diff --git a/plugins/ui/libs/d2e-ui/src/components/D2eButton.vue b/plugins/ui/libs/d2e-ui/src/components/D2eButton.vue
index edf6518d99..f3dea55d75 100644
--- a/plugins/ui/libs/d2e-ui/src/components/D2eButton.vue
+++ b/plugins/ui/libs/d2e-ui/src/components/D2eButton.vue
@@ -15,30 +15,10 @@
-
-
-
-
-