Skip to content

refactor(webui): adopt every shared @lablup/ui-common component #1902

Description

@inureyes

Part of #1910.

Part of #1834.

Problem / Background

The bundled WebUI depends on @lablup/ui-common@0.1.0-alpha.19 (webui/package.json:23, which arrived with #1863) and uses 7 of the 17 components that package exports. Five of the remaining ten are duplicated by local code and five have no local counterpart at all. The goal recorded on 2026-09-16 is that the WebUI uses the shared components everywhere it can, so this issue migrates all ten.

Current Behavior

webui/node_modules/@lablup/ui-common/dist/index.d.ts re-exports 17 components: Badge, BaseCard, Button, DataTable, Drawer, EmptyState, ErrorState, PageHeader, PageLayout, ProgressBar, Select, Skeleton, SmoothHeight, StatCard, StatusTag, Tabs, Tooltip. The Skeleton entry also exports SkeletonCard, SkeletonChart, SkeletonRow and SkeletonText, the StatCard entry exports formatCompactNumber, and dist/hooks/index.d.ts exports usePrefersReducedMotion. There is no Dialog and no Modal anywhere in that export surface.

Seven are in use: Button, DataTable, EmptyState, ProgressBar, StatusTag and Tabs through webui/src/design-system/common-adapters.tsx:3-8,62, and Select through webui/src/design-system/common-select.tsx:3. Those two files plus webui/src/main.tsx:17, which imports styles/base.css, are the only three files in the whole application that import @lablup/ui-common; every page composes the local design-system surface instead. That is why this migration is almost entirely confined to the design-system directory rather than spread across the feature screens: webui/src/design-system/primitives.tsx is 194 lines and webui/src/design-system/shell.tsx is 110 lines.

Proposed Solution

Adopt each shared component behind the existing adapter seam (common-adapters.tsx, common-select.tsx) and keep re-exporting from primitives.tsx, so feature screens continue to import from ./design-system/primitives and the diff stays in the design system.

  • Tooltip replaces the local Tooltip (primitives.tsx:132-140), a direct duplicate of a component the package already ships. Props change from {label, children} to {content, children}, and the wrapper defaults to tabIndex={0}, so pass tabIndex={-1} when wrapping an already-focusable control such as the Button at webui/src/gallery.tsx:45. The shared Tooltip portals its content to document.body (createPortal in dist/components/Tooltip/Tooltip2.js), which is exactly the condition NativeModalContext (webui/src/design-system/modal-context.ts:5) exists for: a body portal sits outside a native showModal() top layer. Dialog stays local and stays native, so follow the common-select.tsx:14 precedent and fall back to local markup when NativeModalContext is true.
  • Drawer replaces the local Sheet (primitives.tsx:128-130), whose only call site is the off-canvas sidebar at webui/src/design-system/shell.tsx:67-69 that the layout contract requires below 960 px and at 390 px. Sheet is a native <dialog> driven through the shared ModalDialog (primitives.tsx:38-111), while Drawer renders <aside role="dialog" aria-modal="true"> inside a backdrop <div> and is not a <dialog> element at all. Two consequences must be handled: the shortcut guard at shell.tsx:32 suppresses Cmd/Ctrl-K and ? via target.closest('dialog[open]'), which returns null for a Drawer, so those global shortcuts would start firing while the mobile nav is open; and NativeModalContext must stop being asserted for that subtree. Props become {isOpen, onClose, title, closeLabel, width} with no testId, so route data-testid="mobile-nav-sheet" (shell.tsx:67) through className or a wrapper. Drawer carries its own Tab cycle, Escape close and restore of document.activeElement on cleanup, so the behavior is preserved but by different code.
  • ErrorState replaces the local ErrorBanner (primitives.tsx:26-35), which has 23 JSX call sites across 13 files. Three contract differences need a decision before the swap: ErrorBanner renders role="status" for tone="info" and role="alert" otherwise, while ErrorState is always role="alert" aria-live="polite"; ErrorBanner titles with <strong> while ErrorState emits <h2>, which lands inside sections that already own an <h2> (for example webui/src/features/activity/runtime.tsx:17); and ErrorBanner takes action?: ReactNode while ErrorState takes primaryAction: {label, onClick}, which cannot express the anchor at webui/src/features/chat/chat.tsx:133. Tone maps error to danger, warning to warning, and info to accent.
  • BaseCard replaces the single local card markup, .surface-card at webui/src/gallery.tsx:36,42,70. BaseCard's root class is base-card, so either preserve .surface-card through className or update every consumer of that selector: webui/src/styles.css:35,40,58,59,64, webui/src/design-system/components.css:32,70,71, webui/tests/browser-assertions.ts:111-113 and the high-contrast border assertions at webui/tests/browser.spec.ts:213,217. BaseCard accepts the tabIndex and ariaLabel that the data card at gallery.tsx:70 already uses.
  • PageLayout and PageHeader take over the layout and header responsibilities of AppShell (shell.tsx:19-92). PageLayout is a width container only (standard 900px, wide 1400px, full), so AppShell keeps .app-shell, .app-toolbar and .app-content-grid and delegates the content column to it. PageHeader owns title, description, actions and an inline error with onRetry and onErrorDismiss, replacing the .screen-heading blocks at webui/src/app.tsx:137, webui/src/gallery.tsx:14 and webui/src/provider-surfaces.tsx:82,108. PageHeader does not forward arbitrary attributes, so find a home for data-dialog-focus-fallback on the settings <h1> before the swap: restoreModalFocus (primitives.tsx:113-122) queries that attribute and webui/src/design-system/modal-focus.test.tsx asserts on it.
  • Badge, Skeleton (with SkeletonCard, SkeletonChart, SkeletonRow and SkeletonText), SmoothHeight and StatCard have no local counterpart today. They are wanted because the current design needs refactoring, not merely to remove duplication, so put them to use rather than only wiring them up. Grounded targets: StatCard for the <dl className="activity-metric"> tiles at webui/src/features/activity/runtime.tsx:19, which already carry a label, a value and an unavailable reason; Skeleton for the DataTable loading slot at webui/src/features/models/screen.tsx:420-421, currently a bare <p role="status">; SmoothHeight for the lists whose height changes as operations arrive under webui/src/features/activity/; Badge for secondary model metadata on the library rows. A different placement is fine if the PR argues it.

Two smaller overlaps surfaced in the same survey and need a recorded decision rather than an assumption:

  • usePrefersReducedMotion (dist/hooks/usePrefersReducedMotion.js) reads only window.matchMedia('(prefers-reduced-motion: reduce)'). Reduced motion in this app is currently handled by CSS media queries plus an explicit in-app override written to data-reduce-motion by webui/src/design-system/preferences.ts:104 and honored at webui/src/design-system/tokens.css:104-105 and webui/src/design-system/components.css:68-69. The hook cannot see that override, so adopting it unchanged would ignore the Settings toggle. Either wrap it so the explicit preference wins, or record that CSS stays the only mechanism.
  • formatCompactNumber returns 1.2K and 3.4M and otherwise falls back to a locale-less toLocaleString(), while the local webui/src/design-system/format.ts threads an explicit Locale into ko-KR or en-US through Intl.NumberFormat for formatBytes and formatTokensPerSecond. Either give it a locale-correct wrapper for the surfaces that want compaction, or record that format.ts stays the single formatter.

Scope

In scope: webui/src/design-system/ (primitives.tsx, shell.tsx, common-adapters.tsx, common-select.tsx, components.css), the call sites those adapters force to change, the .surface-card and .screen-heading selectors in webui/src/styles.css, and the affected tests under webui/src/ and webui/tests/.

Out of scope: the local components that have no counterpart in the package stay local: Dialog, Field, Icon, DenseList, StaticTable, Inspector, AuthGate, LoginView, SchemaMismatchView. Dialog was expected to be in the package and is not, verified against the export surface above. Contributing Dialog upstream to ui-common is a follow-up and must not be opened as part of this issue, but it is the strongest candidate because it carries the focus trap and the Escape-with-focus-restoration behavior (primitives.tsx:38-122) that the package has no equivalent for, and that is worth recording before it is lost.

Implementation Notes

  • Risk: the risk here is not compilation, it is silent accessibility regression. The keyboard behavior these components carry was manually verified against the installed artifact on 2026-09-16: Tab and Shift-Tab order with visible focus, Escape closing dialogs and sheets with focus restored to the opener, and native 200 percent zoom with no horizontal page scroll at 390 px. Swapping a local component for a shared one can break any of that without failing a single existing test.
  • Required order, per component: write a regression test covering the behavior, show it failing with the swap reverted, then swap, then show the same test passing. A test written after a green swap proves nothing. webui/src/design-system/common-adapters.test.tsx and webui/src/design-system/modal-focus.test.tsx are the established homes for these.
  • Drawer and PageLayout additionally need the 390 px off-canvas case and the 200 percent zoom case covered, because those are the layout contract points the shell owns.
  • The existing browser specs drive controls by role (webui/tests/browser-fixtures.ts:157 opens the nav with getByRole('button', ...)), so they pass through a changed focus target or a changed element type without necessarily noticing. Do not treat a green pnpm browser as evidence about focus.
  • Recommended phasing by risk, as a recommendation rather than a mandate: Tooltip first since it is a pure duplicate, then ErrorState and BaseCard, then Drawer, then PageLayout and PageHeader last because the shell has the highest blast radius, with Badge, Skeleton, SmoothHeight and StatCard folded into the design refactor where they belong.
  • Screenshot baselines move: the 390-opaque-gallery-controls-drawer-cjk fixture (webui/tests/browser-fixtures.ts:32,157) opens the nav sheet, so its baseline and the SHA256 table in webui/tests/screenshots/README.md need regenerating once Sheet becomes Drawer.

Acceptance Criteria

  • All ten of Badge, BaseCard, Drawer, ErrorState, PageHeader, PageLayout, Skeleton, SmoothHeight, StatCard and Tooltip are in use, or any one that is not carries a recorded reason.
  • The count of @lablup/ui-common components used is stated in the closing comment, measured the same way as above: the imports found by grep -rn '@lablup/ui-common' webui/src/ against the 17 names in dist/index.d.ts.
  • No local component duplicates a shared one: Tooltip, Sheet and ErrorBanner no longer exist as local implementations in primitives.tsx.
  • Keyboard order, focus restoration, the 390 px off-canvas layout and 200 percent zoom behavior are covered by tests that were each shown to fail before their swap, with the failing output quoted in the PR.
  • The decisions on usePrefersReducedMotion and formatCompactNumber are recorded, in the PR body or in a code comment at the relevant seam.
  • A follow-up note exists for contributing Dialog upstream to lablup/ui-common.
  • Every adopted component is reached through the real screens and working UI, not left as an unused export inside the design system.

Verification

cd webui
pnpm typecheck
pnpm lint
pnpm unit
pnpm browser
pnpm verify-generated

A pass is a clean exit from all five, with pnpm unit reporting the new regression tests and pnpm verify-generated reporting no drift between webui/ and the checked-in src/webui/assets/ bundle. Manual acceptance that no automated check covers: at 390 px open the nav, Tab through it, press Escape, and confirm focus returns to the menu button; then set native browser zoom to 200 percent and confirm there is no horizontal page scroll.

Technical Considerations

This is a child of epic #1834, whose accessibility contract (keyboard-first navigation, visible focus, focus restoration, 200 percent zoom, and an off-canvas sidebar at 390 px with no page-level horizontal overflow) is the standard these swaps must not regress. The @lablup/ui-common dependency itself arrived with #1863.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:architectureArchitecture and code structure changespriority:mediumMedium prioritystatus:doneCompletedtype:refactorCode restructuring without changing functionality

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions