Tailwind/shadcn Migration Notes
Reference for the Bootstrap/Metronic → Tailwind/shadcn migration. Documents the current architecture and the non-obvious decisions behind it — the things a source comment can point to instead of re-explaining inline.
This file intentionally does not narrate the bug-by-bug history of how
each piece got here — that's in git log and the commit messages for the
files named below. What's here is the state of the system today and the
reasoning a future change in this area needs to not re-break.
Status: Tailwind is enabled app-wide (vite.config.ts + src/index.tsx,
see below), and the app's interactive floating-panel UI — dropdowns,
popovers, the sidebar accordion — now runs on Radix primitives across the
whole codebase (no Dropdown/OverlayTrigger+Popover import from
react-bootstrap remains, and Metronic's own imperative MenuComponent.ts
has been deleted entirely). Appearance has not moved: every one of these
Radix-driven panels still wears Bootstrap's .dropdown-menu/.popover or
Metronic's .menu-sub-dropdown classes and renders through the existing
compiled CSS — only the behavior (open/close, focus, keyboard nav,
positioning) is on Radix. BaseButton (the Tailwind/shadcn rebuild in
packages/ui) is still reachable only via Storybook — no production button
has been switched over to it yet.
Architecture
Cascade layers: Bootstrap and Tailwind coexisting
src/tailwind.css imports Tailwind's theme/preflight/utilities pieces
separately (not the @import "tailwindcss" shorthand) and declares an
explicit layer order up front:
1 | |
style.scss/style.dark.scss wrap their entire compiled Metronic output in
@layer bootstrap { @import 'init'; }, so the browser merges both into one
layer at this position regardless of load order (CSS layer order is decided
by first occurrence across the whole document). bootstrap sits between
Tailwind's base and utilities — ranking it below utilities lets
Tailwind win ordinary utility-vs-Bootstrap conflicts, while still outranking
base/preflight.
Layering does not beat !important. Bootstrap 5's utility-API classes
(.border, .bg-transparent) generate with !important, which sits in a
separate priority tier above all layered rules regardless of layer order.
BaseButton.tsx uses border-[1px]/bg-[transparent] instead of the
identically-named Tailwind utilities for exactly this reason.
The @layer bootstrap wrapper ships in the real app, not just
Storybook — and unlayered CSS beats layered CSS regardless of specificity.
That means every one of the app's ~99 component stylesheets imported from a
.tsx (import './Foo.scss', Vite injects it unlayered) ranks above all of
Metronic, including where Metronic previously won on specificity. Rule:
component SCSS must be scoped under its own class, never under a Metronic
layout root (.aside, .header, .toolbar, …) — nesting a Metronic class
inside your own class is fine (that already outranks Metronic on
specificity, so the layer change is a no-op), but a rule scoped directly
under a layout-root class silently wins unconditionally once componentized.
Anything that genuinely needs to override Metronic layout belongs in
src/metronic/sass/custom/, inside the layer.
Enabling Tailwind app-wide
Two lines turn it on: tailwindcss() in vite.config.ts's plugins, and
import './tailwind.css' in src/index.tsx. .storybook/main.ts keeps its
own separate instance of the plugin — both are needed and share
src/tailwind.css, so a story and the running app can't drift on layer
order or theme tokens. Must be imported from the entry module, not
wherever first wants a Tailwind class — Metronic's stylesheet is injected at
runtime as its own <link>, and layer order is fixed by the first @layer
occurrence in the document.
The preflight shim
With layering in place, preflight loses to Bootstrap almost everywhere — but
two rules have no Bootstrap counterpart: img/svg/video/... { display:
block } and ol, ul, menu { list-style: none }. Left unshimmed, every
<img>/inline <svg> would drop out of the text baseline, and every bare
<ul>/<ol> — including Markdown-rendered user content (offering
descriptions, terms of service) — would lose its bullets. src/tailwind.css
ends with a small @layer bootstrap { … } block that reverts exactly
those two properties (not explicit values — revert respects
tag-specific UA defaults like audio:not([controls]) { display: none },
which a blanket display: inline would break).
Verifying parity after a change here: snapshot getComputedStyle for every
element, deleteRule the @layer base block, snapshot again, insertRule
it back and confirm the restored snapshot matches — any differing property
is something preflight actively changes. On a real app page, removing all 34
preflight rules currently produces zero differences beyond the shim.
The layer statement is dropped in production builds — Vite's optimizer
removes @layer theme, base, bootstrap, utilities; because the emitted
dist/assets/index-*.css already contains the four blocks in that order,
making the statement redundant within that file. Still correct today
(Metronic's stylesheet is injected by JS strictly after initial parse, so
index-*.css always establishes order first), but the explicit safety net
is gone — worth re-checking (grep -o '@layer [a-z]*{' dist/assets/index-*.css)
if the CSS chunking or theme-loading strategy ever changes.
Root font-size override
Metronic forces html, body { font-size: 13px !important } (12px below
lg). Tailwind's scale is rem-based against a 16px assumption, so
src/tailwind.css's @theme overrides --spacing/--text-sm/--text-base/
--radius-md/--radius-lg with explicit px values. Numbered spacing
utilities (p-3, mt-1) don't pick up the override — Tailwind's compiled
utilities carry their own --spacing inside the higher-priority utilities
layer. Use px arbitrary values (p-[12px]) instead, as BaseButton.tsx
does throughout.
Brand color token bridge
src/tailwind.css's second @theme block points Tailwind's color utilities
at the CSS custom properties the app sets at runtime (--waldur-brand-*,
written once by afterBootstrap.tsx's initCssVariables()) — a naming
bridge, not a color definition. A harness that renders components outside
real app bootstrap (e.g. a Storybook decorator) must seed --waldur-brand-*
itself or brand-reactive styles fall back to invalid-at-computed-value-time.
Dark mode signal
The app toggles dark mode by swapping the entire compiled stylesheet
(loadTheme() in src/theme/utils.ts) — there's no .dark class.
loadTheme() also sets data-theme on <html> as an additional signal.
Tailwind's dark: variant is @custom-variant dark
(&:where([data-theme='dark'], [data-theme='dark'] *)). The stylesheet swap
remains the source of truth for Bootstrap/Metronic styling; data-theme is
additive.
BaseButton (Tailwind rebuild)
packages/ui/src/BaseButton.tsx. Colors come from
packages/design-tokens/src/buttonColors.css, cross-checked against the
real Bootstrap button via getComputedStyle() and against the reference
Figma design system, empirical value winning on disagreement.
- Border is an inset
box-shadow, notborder. A realborderparticipates inborder-boxheight, and this app's fractional rem-based padding chain at 13px root font-size anti-aliases a 1px border visibly thinner than the same border around this component's exact-px box — even with identical width/color.box-shadowsidesteps layout sizing entirely. Consequence: bothsizevariants pad 1px extra on both axes (sm:px-[8px] py-[4px];lg:px-[16px] py-[10px]), and anyfocus:/hover:state with a visible border + ring combines both into one bracket value rather than layering, sincebox-shadowis one property. focus:, notfocus-visible:. Matches Bootstrap's ring, which fires on any focus method including a click;focus-visible:suppresses for pointer-originated focus by design. Consequences:active:shadow-noneeverywhere (a mouse press matches:focus+:activesimultaneously — Bootstrap fully suppresses the ring while pressed), andfocus:bg-[...]resets on soliddanger/warning/success(so the post-click, still-hovered state doesn't keep the hover tint on top of focus).- Focus ring color is its own per-variant token
(
--btn-<variant>-focus-ring), not derived from border/text color, and light/dark are independent values, not a computed lighten/darken — e.g.primary/text-primary's ring isbrand-600light butbrand-500dark (a brighter ramp step, not the same index carried over). - Pressed-state colors are their own ramp step, not hover's color
reused — see the color tables in
buttonColors.cssfor the per-variant values. All fivetext-*(ghost) variants render no background at all while pressed (active:bg-[transparent]), since:hover+:activematch simultaneously and nothing would otherwise outrankhover:bg-[...].
Dropdown/menu system map — which one to reach for
Four parallel systems coexist, each solving "a floating panel anchored to a trigger" for a different visual language. Reaching for the wrong one is the single most common way to reintroduce bugs this migration already fixed once, so the choice is recorded here rather than re-derived per file:
src/navigation/NavMenu.tsx—NavMenuContent/NavMenuSubContent(RadixDropdownMenu) andPopoverMenuContent(RadixPopover). Use for anything wearing Metronic's menu skin:.menu-sub-dropdown,.menu-link,.menu-item, themenu-gray-*/menu-state-bg-*theme classes — header/footer/sidebar chrome (user dropdown, language selector, sidebar flyouts, role pickers).src/table/ActionsDropdown.tsx—ActionsDropdownComponent/ActionsDropdownItem(RadixDropdownMenu) andActionsPopoverComponent/ActionsPopoverItem(RadixPopover), plusPlainActionItemfor the one context with no real Menu/Popover ancestor at all (ModalActionsDialog's search results). Use for Bootstrap-skinned menus:.dropdown-menu,.dropdown-item,.popover/.popover-body. The default choice for anything new unless the surrounding UI is specifically Metronic chrome (rule 1) or already on the Tailwind design system (rule 3).packages/ui/src/DropdownMenu.tsx/Popover.tsx— the Tailwind/shadcn primitives, styled via CSS variables. Not wired into the production bundle yet (Storybook-only) — don't reach for these insrc/until the restyle is a deliberate, separately reviewed step.Popover.tsx's file comment carries the rule rules 1 and 2 both inherit: if it contains anything the user types into or drags, it's a Popover; if every child is a command row, it's a DropdownMenu. ADropdownMenuowns focus with a roving tabindex and treats keystrokes as typeahead over its item collection — it will steal keystrokes from a focused text input the moment one matches a sibling row's label.- Hover/focus-triggered
OverlayTrigger+Popover/Tooltipfromreact-bootstrapdirectly — deliberately untouched. These are rich tooltips (volume-discount math, truncated-list previews), not click-toggle menus. Radix'sPopoverhas no built-in hover trigger, so converting one would mean hand-rolling mouseenter/mouseleave-with-delay logic to replace what Bootstrap already provides — not worth it absent a specific bug.
Every trigger in an asChild chain must forward refs and props
Radix's Slot clones its immediate child, attaches the popper's positioning
ref, and merges in aria-haspopup/aria-expanded/data-state plus pointer
and keyboard handlers. Any component in that chain that doesn't forward
both breaks it — usually silently, as a button that looks correct and does
nothing. BaseButton and Tooltip (packages/ui) are both forwardRef
and relay ...rest for this reason; Tooltip's no-label early return uses
Slot rather than a bare fragment, since a fragment takes neither ref nor
props. Any custom component sitting directly inside a Trigger/Anchor asChild
needs the same treatment — src/core/Tooltip.tsx's Tip does not
currently do this (a known, deliberately-unfixed hazard; wrap the trigger
around Tip, not the other way — see ActionsDropdown.tsx's
TableDropdownToggle for the reference shape).
ActionsDropdown/ActionItem: the Bootstrap-skinned menu shell
src/table/ActionsDropdown.tsx / src/resource/actions/ActionItem.tsx.
Reachable from ~184 and ~520 files respectively — the highest-leverage
single primitive in the app.
- One axis at a time. Keeps
.dropdown-menu/.dropdown-item/.dropdown-toggleclass names on the Radix-driven markup rather than also restyling ontopackages/ui's TailwindDropdownMenu— those classes come from Bootstrap's own_dropdown.scss, unrelated to Metronic's menu stylesheet, so this doesn't block deleting that stylesheet later. The eventual Tailwind restyle stays a separate, reviewable step. - What Radix replaces outright: the old module-level pub/sub that
closed every other open dropdown instance (Radix dismisses on outside
pointer events natively), and the manual
createPortal(Radix's ownPortal).modal={false}on the Root is deliberate — a modal Radix menu blocks outside pointer events and locks scroll, neither of which the Bootstrap dropdown did. - Keyboard highlight needs a bridge. Bootstrap styles
.dropdown-item:hover/:focus; Radix marks the active row with[data-highlighted](set for both keyboard nav and pointer hover) and manages focus itself, so relying on:focusleaves arrow-key nav unhighlighted.src/metronic/sass/custom/_dropdown.scssmaps.dropdown-item[data-highlighted]/[data-disabled]onto Bootstrap's hover/disabled treatment. .dropdown-togglestays on trigger buttons even though Radix supplies its ownaria-haspopup/data-state— removing it breaks.disabled-view'stable .dropdown-toggle, .dropdown-toggle.btn-icon { display: none }rule.ActionsDropdownShellPropsvs.ActionsDropdownPropsare deliberately different types.ActionsDropdownComponent's...restspreads ontoRadixDropdownMenu.Content, a real DOM element — widening its prop type to the wrapper's fields (data,row,refetch, …) would let a caller pass e.g.data={{}}straight through as a stray DOM attribute. Keep the split when touching either type.- Menu-only exports:
ActionsDropdownItem(forwardRef,onSelectnotonClick),ActionsDropdownItemText,ActionsDropdownHeader,ActionsDropdownSeparator— every bare react-bootstrapDropdown.Item/.Divider/.Headerinside one of these menus must use these instead; a plain element renders and is clickable but is invisible to arrow-key nav/typeahead and doesn't close the menu. - A menu row cannot render standalone. Radix's
Itemthrows outside aDropdownMenu.Root/Content.src/test/harness.tsxexportsinActionsMenu(children)— an already-open, non-modal, trigger-less Root/Portal/Content — for testing a row component in isolation:renderWithProviders(inActionsMenu(<DeleteCreditButton row={row} />)). ActionsPopoverComponent/ActionsPopoverItem— the Popover-backed twin, same Bootstrap classing, for any menu whose content includes a real form control (aDropdownMenuwill hijack that control's keystrokes as typeahead the moment one matches a sibling item's label — see the system map above).PlainActionItem— a plain native<button>with zero Radix dependency, same.dropdown-itemappearance andonSelectAPI.ActionItemswitches to it via anotInMenucontext flag for the one real context with no Radix ancestor at all (ActionDialogBody's "show all actions" search, a plain react-bootstrapModal).- Row-level disabled styling: a disabled Radix menu item gets
pointer-events: none(_dropdown.scss's[data-disabled]rule), so aTip/tooltip explaining why a row is disabled must sit on a separate, non-disabled sibling element (e.g. aQuestionIcon) rather than wrap the disabled item itself — seeUserBulkActions.tsxfor the pattern.
NavMenu: the Metronic-skinned menu shell
src/navigation/NavMenu.tsx. A distinct family from ActionsDropdown.tsx —
these panels were never built on react-bootstrap; they wear Metronic's own
.menu/.menu-sub/.menu-sub-dropdown/.menu-item/.menu-link classes
and, pre-migration, were driven by Metronic's imperative MenuComponent
(now deleted entirely — see below).
- Same "one axis at a time" principle: Radix supplies
behavior/positioning/accessibility, the existing compiled Metronic CSS
supplies 100% of appearance.
.menu-sub-dropdown's visibility/entrance animation is gated by Metronic's own compiled&.show[data-popper-placement]rule (Popper.js's attribute — its presence, not value, gatesdisplay);NavMenuContent/NavMenuSubContentset that same class and attribute so the rule fires unmodified. - Keyboard highlight bridge is harder than
ActionsDropdown's: Metronic ships many.menu-state-*color themes, not Bootstrap's one. The bridge only covers the themes actually used in the app (menu-state-bg-gray,menu-state-bg-light,menu-state-title-primary), callingmenu-link-themewith the same literal arguments_theme.scssalready passes for:hover, retargeted onto[data-highlighted]. .menu-item/.menu-linksplit:NavMenuItem/NavMenuSubTriggerattach Radix behavior directly to the inner.menu-linkelement (an<a>/<Link>/<div>), rendering the outer.menu-itemas a plain, non-Radix wrapper for layout only. Any hand-rolled row that instead puts.menu-linkon an inner<span>and.menu-itemon the actual Radix item breaks.menu-link:focus-visible's outline rule, which targets.menu-linkspecifically — useNavMenuItem asChildrather than hand-rolling this split.- Plain (non-
NavMenuItem) content is a first-class case. Anything that must not auto-close the menu on interaction — a settings toggle, a Copy button — renders as a plain child ofNavMenuContent, never registered with Radix's menu machinery, so its own click handler fires undisturbed. This is the header-cluster equivalent ofActionsPopoverComponent: "needs to survive its own click," not "needs a real text input." DropdownMenuSubselection only closes the submenu, not the root — Radix's own default. Not routed around; where it mattered (LanguageSelectorDropdown), the caller already reloads the page a moment later regardless.useHoverMenu()reproduces Metronic'sdata-kt-menu-trigger="{default: 'click', lg: 'hover'}"(click belowlg, hover atlg+) for top-level triggers — Radix's plainDropdownMenuTriggerhas no hover mode at all (unlikeSubTrigger, which does).hoverHandlersare spread onto both trigger and content (amouseleaveon either alone would close the menu while the pointer crosses the visual gap between them), with a 200ms close-on-leave delay ported from Metronic's ownhoverTimeoutdefault.useHoverMenu(false)skips thelg+ gate for the one call site (PageBarTabs.tsx) whose original attribute had no responsive variant.PopoverMenuContent— the shared shell for every ad-hocRadixPopover.Contentwearing this menu skin (AsyncSearchBox,TableFiltersMenu'sFlyoutRow,RoleAndProjectSelectField, etc.), consolidating what used to be six independently hand-copiedside/align/data-popper-placement/classNameblocks into one implementation. TwoTableFiltersMenu.tsxcall sites are deliberately not migrated to it — they needforceMount+ a customcontainer+ a callback ref, which the simple wrapper doesn't expose, and are already the most heavily-tested code in this area; widening the shared shell to fit them wasn't worth the added surface.useMediaQuery/react-responsivetesting gotcha: this project's jsdom has nowindow.matchMediaat all, andreact-responsivecaptures whatever it resolves to at module-import time — reassigningwindow.matchMediainside a test has no effect. Usevi.mock('react-responsive', () => ({ useMediaQuery: mockFn }))instead.
Radix Popover.Portal renders outside the local DOM subtree — watch z-index and modal stacking
Popover.Portal/DropdownMenu.Portal append to document.body by default,
so a portaled panel becomes a sibling of whatever DOM ancestor it
logically belongs to — not nested under it. Two concrete failure modes to
check for on any new panel wrapped in a Bootstrap Modal or scoped
container:
- z-index collisions: Metronic's own menu classes carry Metronic's
internal z-index scale (
$menu, dropdown, z-index), which was never meant to compete with Bootstrap's modal ($zindex-modal: 1055). A panel that needs to render above a modal should use Bootstrap's own$zindex-popover(1070) token, not an arbitrary bumped number — Radix's Popper mirrorsContent's own CSSz-indexonto the positioning wrapper, so this is a CSS-only fix. - DOM-query assumptions: any code that does
document.querySelector('#some-container .some-portaled-thing')assuming co-location will silently stop matching once the queried content is portaled todocument.body. Pass an explicitcontainertoPortal(e.g.document.getElementById('kt_content_container')) to restore co-location where something outside React depends on it — but note the container lookup runs during React's render phase, before commit, so it can returnnullon a tree's very first render if the container mounts in the same commit as the portaled content.
The sidebar navigation accordion: Collapsible, not Accordion
src/navigation/sidebar/MenuAccordion.tsx and friends — the left sidebar's
in-flow expand/collapse tree, the one piece of the dropdown/menu migration
that needed a genuinely different Radix primitive (no floating panel at
all).
@radix-ui/react-collapsible, notreact-accordion.Accordion.Rootrenders its own wrapping DOM element; a nested Root (needed for "only one sibling open" insideResourcesMenu's recursive categories) would insert an extra<div>between.menu-sub-accordionand its.menu-itemchildren, breaking the indentation mixin's direct-child selector chain. Collapsible has no group-level Root — each.menu-item.menu-accordionis its ownCollapsible.RootviaasChild(zero extra DOM), and sibling-exclusivity is a small shared hook,useExclusiveOpen()(sidebar/utils.ts).Collapsible.TriggertakesclassName="menu-link"directly (no wrapping<span>, noasChild), making it a real<button>— core SCSS already has a reset block for this (button.menu-link,core/components/menu/_base.scss). Consequence: anything rendered into the accordion header'sbadgeslot must not itself be a<button>(invalid nested-button HTML) —ResourcesMenuFilterButton.tsxrenders<span role="button" tabIndex={0} onKeyDown={...}>for this reason. Also consequence:.menu-link's width comes fromflex: 0 0 100%, which only does anything inside a flex parent — a plain block-level<a>fills its parent's width by default regardless, but a<button>keeps its own fit-content intrinsic sizing unless something explicitly stretches it..aside .menu .menu-item .menu-link { width: 100%; }incustom/_aside.scssis scoped to the sidebar specifically, sincebutton.menu-linkis also reused byFooterDropdown.tsxfor a horizontal (not full-width) item.Collapsible.Contentuses Radix's default hidden-attribute-driven mount/unmount, deliberately notforceMount.forceMountlooks like the natural fix for CSS[hidden]-fighting (see below), but it silently breaks Radix's own--radix-collapsible-content-heightfreshness: that var is only re-measured via a real mount/unmount-triggered state update, andforceMountpins the component permanently "present," turning every toggle after the first into a no-op for that measurement. The accepted cost of not usingforceMountis a cross-layer!importantinstead (see below) — a narrower, better-understood problem than losing height measurement.- Height is measured manually into
useState, not read from Radix's own CSS var, for the very first open.CollapsibleContentImplmeasures height into a plainuseRef(notuseState) — updating a ref doesn't re-render, and the only thing that would otherwise force a second render is asetIsPresent(present)call that's a no-op bailout on a component's first-ever open (state already equalspresent). Net effect: the height var never reaches the DOM on a fresh mount's first open, and any@keyframesreading it animate to nothing.MenuAccordion.tsxsidesteps this by measuringcontentRef.current.scrollHeightitself into realuseState(re-renders on every measurement) via a single long-livedResizeObserverper component instance, keyed to re-run on[open]changes (not[]—MenuAccordionitself stays mounted across the whole open/closed lifecycle, so an empty deps array would attach the observer exactly once, whilecontentRef.currentis still null). - Animate via
animation/@keyframes, nottransition. Radix's ownCollapsibleContentImplsynchronously disables (transitionDuration = '0s',animationName = 'none'), force-reflows viagetBoundingClientRect(), then restores — a disable→reflow→restore dance that reliably restarts a namedanimationbut gives atransitionnothing to interpolate from (no intervening painted frame at the old value). Match Radix's own pattern:animation: kt-menu-accordion-down/-upkeyframed againstvar(--radix-collapsible-content-height)(or the manually-measured--menu-accordion-heightvar above), not atransition: heightrule. - Two separate cross-layer
!importantgaps to know about, both from Tailwind's preflight loading in an earlier@layerthan Metronic's own compiledbootstraplayer (cascade layers reverse!importantpriority — an earlier layer's!importantbeats a later layer's regardless of selector specificity, so nothing insidebootstrapcan out-rankbaseon this axis): - Tailwind's
[hidden]:where(...) { display: none !important }(preflight,@layer base) permanently hidesCollapsible.Content's closed resting state even after a matching[data-state]override, so.menu-sub-accordion[data-state] { display: flex; }needs its own!importantincustom/_aside.scssto win. - core's own two
.menu-sub-accordiondisplay: nonerules (one top-level, one nested inside a breakpoint mixin) also need overriding the same way — apply the override to bothopenandclosedstates, since Radix'sPresencekeeps the node mounted withdata-state="closed"for the duration of the closing transition before actually unmounting, anddisplay: noneduring that window would freeze the transition before it plays. - Arrow rotation and the open-state highlight key off
[data-state], not Metronic's.hover/.showclasses (nothing sets those under Radix). Both rules need their owntransitiondeclared on an unconditioned base selector, not only inside the[data-state='open']conditional block — a transition only animates if the element's current computed style already declares it, and the moment a conditional selector stops matching (closing),transitionreverts to unset along withtransform, so the open animation plays but the close snaps. This is a recurring shape across every Radix-driven caret/arrow bridge in this codebase, not unique to the accordion — check for it (grepfortransitionliving only inside a[data-state=...]/.show-gated block) on any new one. - Deviation from Metronic's exact algorithm, accepted deliberately:
Metronic's
_hideAccordionsnever actually clears a nested item's own.showwhen its parent collapses, so a collapsed-then-reopened category remembers it was expanded. Radix'sContentunmounts on close, resetting nested state. Accepted since real nesting only goes 2 levels deep today. - Route-driven auto-expand: a
useEffecton route change calls the same shareduseExclusiveOpen'ssetOpenId— a one-shot "open it" on route match, never the equivalent of.hide(), so a route-active section the user manually collapsed doesn't reopen until the next matching navigation. MenuComponent.tshas been deleted entirely, along with itsbootstrap()/reinitialization()calls inMasterInit.tsx/MasterLayout.tsxand its barrel export. Verify before assuming otherwise: a repo-wide grep fordata-kt-menushould turn up nothing live.Sidebar.tsx's other Metronic widgets (DrawerComponent,ScrollComponent,ToggleComponent— mobile drawer, custom scrollbar, minimize toggle) are unrelated and untouched.
Testing gotcha: ResizeObserver and animation timing
jsdom doesn't implement ResizeObserver — stub it in tests that render
Collapsible.Content (vi.stubGlobal('ResizeObserver', class { observe(){}
unobserve(){} disconnect(){} }), see AssistantComposer.test.tsx for the
precedent). Separately, when live-debugging a CSS animation in an
automated/CDP-driven browser tab: getAnimations()/getBoundingClientRect()
polling via setTimeout is not reliable — such tabs appear to skip
compositing animation frames unless something explicitly forces a render.
A tight burst of screenshot calls across the interaction (CDP forces a
real paint per capture) is the reliable substitute for confirming an
animation genuinely interpolates rather than snapping.
packages/ui: portable Tailwind/Radix primitives
Holds BaseButton's dependency graph with zero Bootstrap coupling:
cn()— class-name merge helper.LoadingSpinner— Tailwind'sanimate-spin; distinct fromsrc/core/LoadingSpinner.tsx'sLoadingSpinnerSimple, which ~385 call sites elsewhere still use unchanged.Tooltip—@radix-ui/react-tooltip-based rebuild ofsrc/core/Tooltip.tsx'sTip, scoped toTip's actual usage (label+ optionalbody, hover/focus trigger, dark bubble theme only) rather than its fuller react-bootstrap-derived API.BaseButton— see above. Internal imports ofcn/LoadingSpinner/Tooltipare relative (./cn), not round-tripped through the package name.
src/core/buttons/BaseButtonParity.stories.tsx is the one place both
buttons render side by side, importing the new one as BaseButtonTw from
waldur-ui purely for local readability.
Storybook toolchain
yarn storybook (dev, port 6006) / yarn build-storybook.
- Stories:
BaseButton.stories.tsx/BaseButtonTw.stories.tsx(variant × size matrix) plusBaseButtonParity.stories.tsx(Migration/BaseButton Parity, taggeddata-pair/data-role) for the Playwright parity spec to screenshot. Plain stories usestorybook-addon-pseudo-statesfor quick visual hover/focus/active browsing; the parity story instead drives real Playwright interactions, since forced pseudo-states aren't reliable for the real Bootstrap button's compiled CSS. .storybook/main.ts'sviteFinalhand-duplicatesvite.config.ts'sresolve.alias/css.preprocessorOptions/definerather than reusing itspluginsarray wholesale (that array'sreact()would double up with@storybook/react-vite's). Keep the duplicated values in sync by hand..storybook/preview.tsx's theme toggle callsloadTheme()directly — the same function the real app'sThemeProvidercalls.- Vitest project split (
vitest.config.ts):unit(jsdom) andstorybook(browser-mode via@vitest/browser-playwright, renders every story as a smoke test,yarn test:storybook) need different CI images —.gitlab-ci.ymlpasses--project=unitexplicitly for the unit job. Thestorybookproject'soptimizeDeps.includeexplicitly listsaria-query,lz-string,pretty-format— without them Vite's dependency scanner can't see into@storybook/addon-vitest's build artifact to discover their transitive CJS deps, and each hits a browser-native interopSyntaxErrorat import time instead.
Visual parity test suite (e2e-visual/base-button-parity.spec.ts)
Screenshots the old (Bootstrap) and new (Tailwind) BaseButton side by side
on the BaseButtonParity story and diffs the buffers directly — no
committed baseline to go stale.
1 | |
(--workers=1 is required — a full run at --workers=3 exhausted available
RAM on this machine.)
Coverage: 12 variants × 2 sizes × 2 themes × 6 states (enabled,
disabled, hover, active, focus via .focus(), focus via a real
.click()) = 288 cases.
Checks, in order:
- Dimension parity (
MAX_DIMENSION_SLACK_PX = 0.5) — compareslocator.boundingBox()(exact CSS px), not the screenshot PNG's rounded integer dimensions — PNG-based comparison rounds based on the element's exact fractional page position, so a real regression and pure noise can round to the identical delta depending on placement. Measured noise ceiling viaboundingBox()is ~0.06px. - Pixelmatch ratio (
DIFF_RATIO_THRESHOLD = 0.16,threshold: 0.25per-pixel) — the primary pixel-diff check, tuned above rendering noise (up to ~13% on text-only/pastel variants) while staying below any real token bug's signal. - Dominant-color chromaticity (
CHROMATICITY_TOLERANCE = 10,FOREGROUND_DISTANCE_THRESHOLD = 30) — closes pixelmatch's blind spot on small/text-heavy buttons, where a wrong hue only touches a small pixel fraction. Compares each channel's share of brightness (chromaticity), which cancels uniform antialiasing-driven lighter/darker shifts while staying sensitive to an actual hue change.
Known, accepted rendering-engine noise (tokens verified byte-identical
via getComputedStyle(); not fixable without literally sharing DOM/CSS
between old and new, which defeats the migration's point): box-shadow
corner rendering at small radius differs by a fraction of a px between
implementations (most visible on sm buttons), and text/edge antialiasing
on pastel backgrounds at lg size depends on sub-pixel glyph position.
Testing gotchas
- CSS transitions need a real paint to settle — a synchronous
getComputedStyle()immediately after.hover()/.focus()reliably returns the pre-transition value.gotoParity()disables all transitions/animations page-wide via an injected stylesheet;waitForTimeout(350)before each state screenshot is a second, independent safeguard. :focus-visibleisn't triggered by a raw.focus()call in Chromium — it requires a plausible keyboard origin.:focus(what this component actually uses) doesn't have that restriction, so the suite's two focus tests (.focus()and.click()) exercise genuinely different states, since:hoverpersists after a click but not after.focus().- Reliably triggering
:active: a fresh browser context per test,scrollIntoViewIfNeeded(),page.mouse.move()to the element's exact center (fromboundingBox(), not a hover-implied position) beforemouse.down(), and an explicitelement.matches(':active')check immediately before capturing — the test throws rather than silently comparing two enabled buttons. storybook-addon-pseudo-states' forced-state toggle is unreliable for the real Bootstrap button's compiled CSS specifically; the parity spec always drives real Playwright interactions instead.