Skip to content

feat(ui): add the d2e component library and the Histoire explorer - #3204

Open
khairul-syazwan wants to merge 33 commits into
developfrom
khairul-syazwan/d2e-ui-library
Open

feat(ui): add the d2e component library and the Histoire explorer#3204
khairul-syazwan wants to merge 33 commits into
developfrom
khairul-syazwan/d2e-ui-library

Conversation

@khairul-syazwan

@khairul-syazwan khairul-syazwan commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Stack 1 of 4 for the D2E data-exploration redesign (#3116).

Adds the component library, packages it as a built artifact, and adds a tracked Histoire explorer. Replaces the legacy Atlas theme and wires the application to the design tokens.

Scope:

  • libs/d2e-ui component library (D2eDialog, D2eButton, D2eTextField, D2eStatusChip, D2eIconButton, D2eMenu, D2eCard, D2eToolbar, D2eExplorationCard) and design tokens.
  • Histoire explorer sub-project under libs/d2e-ui/explorer.
  • Consume @d2e/ui in vue-mri-ui-lib through a source-export alias so the CI atlas build resolves the library without publishing the private package.
  • Packaging (previously feat(ui): build @d2e/ui as a distributable component library #3251, folded in on 2026-09-02): vite lib mode builds dist/index.js and dist/index.css, types via vue-tsc, with vue and vuetify external. Components import their own Vuetify pieces. Constant maps moved out of the SFCs into plain modules so tests do not load Vuetify. scripts/verify-dist.mjs guards the artifact in CI.

#3251 was folded in rather than reviewed separately: it was 25 files and +323/-121 against this PR's +7448, and 8 of those files are components this PR introduces. Reviewed apart, those SFCs had to be read twice — once with the constant maps inline, then again with the maps extracted. The final state is clearer read once. The branch was fast-forwarded, not force-pushed, so no commit was rewritten.

Notes:

  • Dialogs follow the Figma modal scale (S 540 / L 900 / XL 1200). The scrim no longer dismisses.
  • Validation: @d2e/ui tests pass (25), app unit tests pass (906) with one inherited app-segmented-button failure that passes in CI, the app and atlas builds pass, verify:dist reports 17 exports with peers external, and the explorer builds and serves on port 6006.
  • Lint is not run in the treehouse worktree.
  • Remaining design questions (modal naming, destructive button category, button height 40/44, Modal/XL responsive) are tracked separately.

(cherry picked from commit 8f22ee68dd96ffd43f8105289599c311770b62e8)
(cherry picked from commit 2e095dec72fc364af0dcb1bfdc9d51e14d999596)
(cherry picked from commit e5a8dbb430e21d58c4c389cfa234ead2ae242631)
(cherry picked from commit 59aadc48cdf285d91eeabdc3e7369687e14fd01b)
…ollisions

(cherry picked from commit 75414bea71e8840a6bc35d7318003d409f3067c2)
Library half of the original formatting commit. The application half
goes with the dialog changes.
(cherry picked from commit 977b92b633cc47993eafd32780fcfad74e72ee8b)
(cherry picked from commit 268c408fa58447db64aac3493d7454b0a8f27bb8)
(cherry picked from commit 50d4ae4682c4b2c3273b3bb246373301b23a396b)
(cherry picked from commit 2bf1177dc6b87f17a66fb79248fe9ce587dfad58)
(cherry picked from commit 7f5bedd16d14b7b6b8fbdcf56a681c3f06bd7045)
The token generator added `px` to each number. This made
`--d2e-font-*-weight` and the ratio `--d2e-font-*-line-height` values
invalid, for example `font-weight: 600px` and `line-height: 1.2px`. The
browser ignored both declarations.

The effect was not visible in the dialogs, because each affected element
holds one line of text. It was visible in D2eExplorationCard, where the
metadata rows collapsed to 1.5 px and printed on top of each other.

Keep `weight`, `lineHeight`, `opacity` and `zIndex` unitless. Add a test
that fails if a weight or a ratio line height gets a unit again.

(cherry picked from commit ca03e1f38574680e87d73a6038774b208e75cb8d)
The explorer was an untracked proof of concept. Track it, so that each
developer can start it with the same dependency versions.

Histoire needs vite 7. `plugins/ui/package.json` pins vite to 6.4.2 with
an override, to hold libs/react-notebook at vite 6. Bun applies an
override to all packages in the workspace. The workspace globs are
`libs/*` and `apps/*`, which match one level only, so this directory is
outside the workspace. It uses npm and its own lockfile, and it can
therefore use vite 7.

Also:

- Align `vue` with apps/vue-mri-ui-lib at 3.5.17. `vuetify` already
  agreed at 3.12.0. The explorer must show what the application renders.
- Add `.nvmrc` and `engine-strict=true`, because the explorer needs
  Node 20.19 or later and a default shell Node can be older.
- Keep the lockfile in git. `plugins/ui/.gitignore` ignores each
  `package-lock.json`, so the explorer negates that rule.
- Add `.prettierignore`, to keep `prettier --write .` away from the
  lockfile and the build output.

(cherry picked from commit d849a464dfeb988e61d20c136a11cff7039c3217)
`histoire.config.ts` and `histoire.setup.ts` were in the library root,
and the `ui:dev` and `ui:build` scripts started them. They cannot run,
because histoire needs vite 7 and the workspace override pins vite 6.

The working copies are now in `explorer/`. Two configurations, where one
of them cannot run, is a trap for the next reader.

This also removes an undeclared dependency problem: the deleted
`histoire.setup.ts` imported `@mdi/font`, `@fontsource-variable/ibm-plex-sans`
and `sass`, which this package never declared. They resolved only through
bun hoisting. The explorer declares all three.

(cherry picked from commit 59ede28af997fa5cb6b1e518cc699a3281adb204)
Give the library a README, and give the explorer a README with one
command sequence that includes the Node version step. Record why the
explorer is outside the workspace, and the two rules that keep it
working.

(cherry picked from commit 65560a77dcee6921c47006d506600c8ca19306aa)
Two properties are load-bearing and silent when they break:

- The workspace globs match one level only. A deeper glob pulls the
  explorer into the workspace, where the vite 6 override stops Histoire.
- The explorer must use the vue and vuetify versions of the application,
  or it shows something the application does not render.

Assert both against the three package.json files. The test reads JSON
only; it mounts no component.

(cherry picked from commit 346ff0f147ae47caac80806d71c671aed12a4e01)
`@histoire/plugin-vue@1.0.0-beta.1` needs `vue ^3.5.26`, and
apps/vue-mri-ui-lib pins `vue 3.5.17`. An exact match makes `npm ci`
fail with ERESOLVE.

Pin 3.5.26 and record the constraint in the README. The parity test
already compares the major and minor version only, so it still holds.

(cherry picked from commit 9e9bbbbc04f97c12b4ded963acc567264680e4c5)
Three gaps found by checking the library against the Figma design system
(file lpbqxd8B0OkRKH9kJoCCEa) through the component explorer.

**Modal scale.** The Figma variables are Modal/S 540, Modal/L 900 and
Modal/XL 1200. There is no Modal/M, and 600 appears nowhere in the design
system, but `tokens.ts` carried `md: 600` and D2eDialog defaulted to it.
Drop the invented size, add a `size` prop over the real scale, and default
to Modal/S — the rename, delete and materialize frames are all Modal/S.
`maxWidth` stays as an escape hatch. A test locks the scale.

**Button icons.** The Figma Button set has a `Sub category` axis of
Default / Icon front / Icon back. D2eButton had none of it. Its `icon`
prop forwarded to `v-btn`'s `icon`, which makes an icon-only round button
and drops the label, so the "with icon" story rendered no label at all.
Replace it with `prependIcon` and `appendIcon`. Use D2eIconButton when an
icon-only control is wanted.

**Scrim dismissal.** MODAL CLOSE BEHAVIOR (2106:162) says clicking the
overlay must not dismiss a modal, for every modal type. The dialog was
dismissable. `v-dialog` is now always persistent, and Escape is handled
directly so informational modals still close on it. `closeOnEscape`
turns that off for long forms and multi-step flows, which the same
frame says must raise a confirm-discard step instead.

The `persistent` prop is gone: the overlay never dismisses now, so the
only remaining axis is Escape.
The single "sizes" story showed one dialog at one size, so the scale was
not demonstrable — the sizes looked like they did nothing.

Give each size its own variant. The `size` prop was in fact working; the
story sandbox was fixed at 720 px, so Modal/L (900) and Modal/XL (1200)
both clamped to the pane and rendered identically. Setting
`responsive-disabled` on the story lets the preview scale with the
window: S renders at 540 everywhere, L reaches 900 and XL reaches 1200
on a wide window, and both clamp below that — the same thing a real
browser does. Each variant states its max-width.

Also turn off the Vuetify ripple on D2eButton, D2eIconButton and the
dialog close button. The Figma button set defines discrete Hover,
Focused and Pressed states and no ripple animation.
… build

The patient-analytics sub-plugin is installed in CI with
`npm install --workspaces=false` inside apps/vue-mri-ui-lib, which
resolves @d2e/ui from the registry and 404s on the private package.

Alias @d2e/ui to libs/d2e-ui/src/index.ts and @d2e/ui/tokens.css to
libs/d2e-ui/src/tokens/tokens.css in the three vite configs and in
tsconfig, and drop the dependency. This keeps the source-export model
and works for both bun and npm.
The shared library carried application-specific naming. `@d2e/ui` is
meant to serve the portal once it moves to Vue, and `mri-brand` reads as
MRI-only to any other consumer.

  mri-brand       -> brand
  mri-brand-hover -> brand-hover
  mri-contrast    -> brand-contrast
  mri-info        -> brand-info

Values are unchanged.

Deliberately **not** renamed to `primary`. The original plan gives two
conditions for that, and only one is met. The Figma extraction is done,
but Bootstrap 4.6.1 is still imported inside `.mri-app-vue-container`
(styles/style.scss:11), so `primary` would still collide: Bootstrap's
scoped `.bg-primary`/`.text-primary` beat Vuetify's, and Bootstrap 4
defines no `--bs-primary-rgb`, so the declaration is invalid at
computed-value time and the button renders transparent. `brand` is
collision-free because Bootstrap's `$theme-colors` has no such entry.
Rename to `primary` once Bootstrap leaves the portal scope.

Nothing outside the library consumed these keys — no `color="mri-brand"`
props and no `bg-mri-*` utility classes. The `--color-mri-brand` CSS
custom property in the application palette is a different namespace and
is untouched.

Verified in the explorer: primary renders rgb(0, 0, 128), secondary and
ghost keep navy text, danger stays rgb(163, 41, 61).
The portal is moving to Vue and will share these components, so the
library needs a real contract rather than a source export that every
consumer compiles itself.

- Build with vite lib mode into `dist/index.js` + `dist/index.css`, types
  via `vue-tsc`. Not the rollup/babel chain libs/portal-components uses:
  that is rollup 2 plus six babel packages for React/TSX, and vite handles
  Vue SFCs natively.
- `vue` and `vuetify` stay external as peer dependencies.
- Components now import the Vuetify pieces they use, so the package works
  without the consumer running vite-plugin-vuetify.
- Entry points move to dist; `./tokens.css` stays on source because it is
  generated from tokens.ts and guarded by tokens:check.
- `scripts/verify-dist.mjs` checks the artifact in CI.

Two things this surfaced:

Adding the Vuetify imports broke three unit tests, which imported their
constant maps from the `.vue` files and so began loading Vuetify's CSS
under vitest's node environment. Moved those maps into plain modules
(`buttonVariants`, `iconButtonSizes`, `statusChipVariants`,
`explorationCardStatus`), following the existing `dialogSizes.ts`
pattern. The public API is unchanged; tests no longer touch Vuetify.

The obvious smoke test — importing dist in plain node — cannot work,
because vuetify's ESM pulls in .css. `verify:dist` checks statically
instead, and asserts *positively* that vue and vuetify are still
imported: when a peer gets bundled it stops appearing as an import, so
looking only for unexpected imports misses the case that matters.
Verified by dropping vuetify from `external` — the check fails on both
the missing import and a 197 kB bundle against the 150 kB ceiling.

Verified: 25 lib tests, tokens:check, dist verify, all three vue-mri
build variants, explorer 8 stories / 30 variants, and every component
still renders in the browser with no console errors.
`verify-dist.mjs` checked 13 names while `src/index.ts` exports 17 values. The
check is a subset test, so the four constant maps this branch added —
VARIANT_MAP, SIZE_MAP, STATUS_CHIP_VARIANT_MAP and ICON_BUTTON_SIZE_MAP — went
unguarded. Dropping or renaming any of them would not have failed the build.
The guard now reports 17 exports.

The README header still said the application uses the source and there is no
build step, directly above the new section on consuming the built package.
Say what is true of both: the application reads source through vite aliases,
and every other consumer uses the artifact.

Found in review of #3251.
- D2eDialog: card is now a flex column with a capped max-height and a
  scrollable body, so a long form or short viewport keeps the header/
  actions visible instead of clipping content.
- D2eTextField: disable attribute inheritance so a caller's listeners
  (blur, click, ...) aren't applied twice — once via forwardAttrs, once
  via Vue's default inheritance onto the same root.
- D2eIconButton: require icon and ariaLabel. There's no content slot, so
  a call missing either produced a blank, unlabeled control.
- D2eMenu: implement the roving-tabindex ARIA menu keyboard model
  (Arrow Up/Down, Home/End) the role="menu"/"menuitem" markup already
  implied but didn't back.
- D2eExplorationCard: derive the checkbox's default accessible name from
  the exploration name instead of a shared "Select exploration" label.

The other six comments on that review were already resolved by later
commits on this branch (main.ts's theme class was removed outright, the
two story docs and explorer/.gitignore's lockfile were fixed in
87be952 and 10aaf0a, and D2eExplorationCard's truncation/tooltip and
"By:" prefix were fixed rebuilding it against the Figma design). Left
the missing D2eDialog behavior tests out — the fix would require
mounting the component in vitest, which the repo's testing policy rules
out; that belongs in Playwright once the dialog is wired into an app.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants