You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.tsre-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 exportsformatCompactNumber, anddist/hooks/index.d.tsexportsusePrefersReducedMotion. 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 throughwebui/src/design-system/common-select.tsx:3. Those two files pluswebui/src/main.tsx:17, which importsstyles/base.css, are the only three files in the whole application that import@lablup/ui-common; every page composes the localdesign-systemsurface 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.tsxis 194 lines andwebui/src/design-system/shell.tsxis 110 lines.Proposed Solution
Adopt each shared component behind the existing adapter seam (
common-adapters.tsx,common-select.tsx) and keep re-exporting fromprimitives.tsx, so feature screens continue to import from./design-system/primitivesand the diff stays in the design system.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 totabIndex={0}, so passtabIndex={-1}when wrapping an already-focusable control such as the Button atwebui/src/gallery.tsx:45. The shared Tooltip portals its content todocument.body(createPortalindist/components/Tooltip/Tooltip2.js), which is exactly the conditionNativeModalContext(webui/src/design-system/modal-context.ts:5) exists for: a body portal sits outside a nativeshowModal()top layer.Dialogstays local and stays native, so follow thecommon-select.tsx:14precedent and fall back to local markup whenNativeModalContextis true.Sheet(primitives.tsx:128-130), whose only call site is the off-canvas sidebar atwebui/src/design-system/shell.tsx:67-69that the layout contract requires below 960 px and at 390 px.Sheetis a native<dialog>driven through the sharedModalDialog(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 atshell.tsx:32suppresses Cmd/Ctrl-K and?viatarget.closest('dialog[open]'), which returns null for a Drawer, so those global shortcuts would start firing while the mobile nav is open; andNativeModalContextmust stop being asserted for that subtree. Props become{isOpen, onClose, title, closeLabel, width}with notestId, so routedata-testid="mobile-nav-sheet"(shell.tsx:67) throughclassNameor a wrapper. Drawer carries its own Tab cycle, Escape close and restore ofdocument.activeElementon cleanup, so the behavior is preserved but by different code.ErrorBanner(primitives.tsx:26-35), which has 23 JSX call sites across 13 files. Three contract differences need a decision before the swap:ErrorBannerrendersrole="status"fortone="info"androle="alert"otherwise, whileErrorStateis alwaysrole="alert" aria-live="polite";ErrorBannertitles with<strong>whileErrorStateemits<h2>, which lands inside sections that already own an<h2>(for examplewebui/src/features/activity/runtime.tsx:17); andErrorBannertakesaction?: ReactNodewhileErrorStatetakesprimaryAction: {label, onClick}, which cannot express the anchor atwebui/src/features/chat/chat.tsx:133. Tone mapserrortodanger,warningtowarning, andinfotoaccent..surface-cardatwebui/src/gallery.tsx:36,42,70. BaseCard's root class isbase-card, so either preserve.surface-cardthroughclassNameor 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-113and the high-contrast border assertions atwebui/tests/browser.spec.ts:213,217. BaseCard accepts thetabIndexandariaLabelthat the data card atgallery.tsx:70already uses.AppShell(shell.tsx:19-92). PageLayout is a width container only (standard900px,wide1400px,full), soAppShellkeeps.app-shell,.app-toolbarand.app-content-gridand delegates the content column to it. PageHeader ownstitle,description,actionsand an inline error withonRetryandonErrorDismiss, replacing the.screen-headingblocks atwebui/src/app.tsx:137,webui/src/gallery.tsx:14andwebui/src/provider-surfaces.tsx:82,108. PageHeader does not forward arbitrary attributes, so find a home fordata-dialog-focus-fallbackon the settings<h1>before the swap:restoreModalFocus(primitives.tsx:113-122) queries that attribute andwebui/src/design-system/modal-focus.test.tsxasserts on it.<dl className="activity-metric">tiles atwebui/src/features/activity/runtime.tsx:19, which already carry a label, a value and an unavailable reason; Skeleton for the DataTable loading slot atwebui/src/features/models/screen.tsx:420-421, currently a bare<p role="status">; SmoothHeight for the lists whose height changes as operations arrive underwebui/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 onlywindow.matchMedia('(prefers-reduced-motion: reduce)'). Reduced motion in this app is currently handled by CSS media queries plus an explicit in-app override written todata-reduce-motionbywebui/src/design-system/preferences.ts:104and honored atwebui/src/design-system/tokens.css:104-105andwebui/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.formatCompactNumberreturns1.2Kand3.4Mand otherwise falls back to a locale-lesstoLocaleString(), while the localwebui/src/design-system/format.tsthreads an explicitLocaleintoko-KRoren-USthroughIntl.NumberFormatforformatBytesandformatTokensPerSecond. Either give it a locale-correct wrapper for the surfaces that want compaction, or record thatformat.tsstays 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-cardand.screen-headingselectors inwebui/src/styles.css, and the affected tests underwebui/src/andwebui/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.Dialogwas expected to be in the package and is not, verified against the export surface above. ContributingDialogupstream 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
webui/src/design-system/common-adapters.test.tsxandwebui/src/design-system/modal-focus.test.tsxare the established homes for these.webui/tests/browser-fixtures.ts:157opens the nav withgetByRole('button', ...)), so they pass through a changed focus target or a changed element type without necessarily noticing. Do not treat a greenpnpm browseras evidence about focus.390-opaque-gallery-controls-drawer-cjkfixture (webui/tests/browser-fixtures.ts:32,157) opens the nav sheet, so its baseline and the SHA256 table inwebui/tests/screenshots/README.mdneed regenerating once Sheet becomes Drawer.Acceptance Criteria
@lablup/ui-commoncomponents used is stated in the closing comment, measured the same way as above: the imports found bygrep -rn '@lablup/ui-common' webui/src/against the 17 names indist/index.d.ts.Tooltip,SheetandErrorBannerno longer exist as local implementations inprimitives.tsx.usePrefersReducedMotionandformatCompactNumberare recorded, in the PR body or in a code comment at the relevant seam.Dialogupstream tolablup/ui-common.Verification
cd webui pnpm typecheck pnpm lint pnpm unit pnpm browser pnpm verify-generatedA pass is a clean exit from all five, with
pnpm unitreporting the new regression tests andpnpm verify-generatedreporting no drift betweenwebui/and the checked-insrc/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-commondependency itself arrived with #1863.