Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ui-test-vue.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions plugins/ui/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions plugins/ui/libs/d2e-ui/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
node_modules/
.histoire/
dist/
26 changes: 24 additions & 2 deletions plugins/ui/libs/d2e-ui/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
18 changes: 15 additions & 3 deletions plugins/ui/libs/d2e-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
99 changes: 99 additions & 0 deletions plugins/ui/libs/d2e-ui/scripts/verify-dist.mjs
Original file line number Diff line number Diff line change
@@ -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(", ")})`,
);
2 changes: 1 addition & 1 deletion plugins/ui/libs/d2e-ui/src/__tests__/d2e-button.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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),
);
});

Expand Down
2 changes: 1 addition & 1 deletion plugins/ui/libs/d2e-ui/src/__tests__/icon-button.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion plugins/ui/libs/d2e-ui/src/__tests__/status-chip.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
4 changes: 2 additions & 2 deletions plugins/ui/libs/d2e-ui/src/__tests__/tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
26 changes: 3 additions & 23 deletions plugins/ui/libs/d2e-ui/src/components/D2eButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,30 +15,10 @@
</v-btn>
</template>

<script lang="ts">
export const VARIANT_MAP = {
// `brand`, not the `primary` theme key: inside the portal scope
// (.mri-app-vue-container) Bootstrap 4's scoped `.bg-primary`/`.text-primary`
// utilities win over Vuetify's same-named utilities and render invalid
// (transparent/blue), because Bootstrap 4 defines no `--bs-primary-rgb`.
// Bootstrap's $theme-colors has no `brand` entry, so this key cannot
// collide. Rename to `primary` once Bootstrap leaves the portal scope.
// Same mechanism as `danger` -> `feedback-error`.
primary: { variant: "flat", color: "brand" },
secondary: { variant: "outlined", color: "brand" },
// The design red is the existing feedback-error token (#A3293D), not the
// Bootstrap red on the theme's `error` key.
danger: { variant: "flat", color: "feedback-error" },
ghost: { variant: "text", color: "brand" },
} as const;

export const SIZE_MAP = { sm: "small", md: undefined, lg: "large" } as const;

export type D2eButtonVariant = keyof typeof VARIANT_MAP;
export type D2eButtonSize = keyof typeof SIZE_MAP;
</script>

<script setup lang="ts">
import { VARIANT_MAP, SIZE_MAP } from "./buttonVariants";
import type { D2eButtonVariant, D2eButtonSize } from "./buttonVariants";
import { VBtn } from "vuetify/components";
import { computed } from "vue";

interface Props {
Expand Down
7 changes: 7 additions & 0 deletions plugins/ui/libs/d2e-ui/src/components/D2eDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@
</template>

<script setup lang="ts">
import {
VBtn,
VCard,
VDialog,
VDivider,
VProgressCircular,
} from "vuetify/components";
import { computed, nextTick, ref, useAttrs, watch } from "vue";
import { DIALOG_SIZE_MAP, type D2eDialogSize } from "./dialogSizes";

Expand Down
34 changes: 6 additions & 28 deletions plugins/ui/libs/d2e-ui/src/components/D2eExplorationCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -89,35 +89,13 @@
</section>
</template>

<script lang="ts">
// Figma: Exploration card component set 1810:239213 (ready / Not run / Stale).
export const EXPLORATION_STATUS_MAP = {
ready: {
variant: "positive",
label: "Ready",
icon: "mdi-check-circle-outline",
},
"not-run": {
variant: "neutral",
label: "Not run yet",
icon: undefined,
},
stale: {
variant: "warning",
label: "Stale",
icon: "mdi-alert-outline",
},
} as const;

export type D2eExplorationCardStatus = keyof typeof EXPLORATION_STATUS_MAP;

export interface D2eExplorationCardRow {
label: string;
value: string | number;
}
</script>

<script setup lang="ts">
import { EXPLORATION_STATUS_MAP } from "./explorationCardStatus";
import type {
D2eExplorationCardRow,
D2eExplorationCardStatus,
} from "./explorationCardStatus";
import { VCheckbox } from "vuetify/components";
import { computed } from "vue";
import D2eStatusChip from "./D2eStatusChip.vue";

Expand Down
18 changes: 6 additions & 12 deletions plugins/ui/libs/d2e-ui/src/components/D2eIconButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,13 @@
</v-btn>
</template>

<script lang="ts">
// Values from design-system/icon-button.md (set 2006:843).
export const ICON_BUTTON_SIZE_MAP = {
sm: { container: 32, icon: 16, padding: 5 },
md: { container: 44, icon: 20, padding: 8 },
lg: { container: 48, icon: 24, padding: 12 },
} as const;

export type D2eIconButtonSize = keyof typeof ICON_BUTTON_SIZE_MAP;
export type D2eIconButtonCategory = "primary" | "secondary" | "no-stroke";
</script>

<script setup lang="ts">
import { ICON_BUTTON_SIZE_MAP } from "./iconButtonSizes";
import type {
D2eIconButtonSize,
D2eIconButtonCategory,
} from "./iconButtonSizes";
import { VBtn, VIcon } from "vuetify/components";
import { computed } from "vue";

interface Props {
Expand Down
1 change: 1 addition & 0 deletions plugins/ui/libs/d2e-ui/src/components/D2eMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
</template>

<script setup lang="ts">
import { VIcon } from "vuetify/components";
export interface D2eMenuItem {
label: string;
value: string;
Expand Down
Loading
Loading