Tailwind/shadcn Migration Notes
Working notes for the Bootstrap/Metronic → Tailwind/shadcn migration
(feature/tailwind-shadcn-migration-phase0). Collects the reasoning,
investigation trails, and gotchas that used to live as long inline comments
scattered across the touched files — kept in one place so source comments
can stay short pointers instead of re-telling the story at every call site.
Current status: Phase 0/1 spike work, not wired into production.
BaseButtonTw is reachable only via Storybook (yarn storybook) —
src/index.tsx has zero Tailwind-migration footprint, unconditionally
rendering <Application />. The former ?tw-spike showcase and
?tw-parity harness routes were both superseded by Storybook stories and
removed (see "Storybook toolchain" below).
Cascade layers: making Bootstrap and Tailwind coexist
src/tailwind.css imports Tailwind's theme/preflight/utilities
pieces separately (instead of 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'; }, using the same layer name so the
browser merges both into one layer at this position — regardless of load
order, since CSS layer order is decided by first occurrence across the
whole document, and this reservation is what establishes that occurrence.
Why this order and not "bootstrap below everything": the first attempt
ranked bootstrap as the lowest-priority layer overall. That broke the
real (Bootstrap) BaseButton outright — Tailwind's preflight resets the
universal selector (*, ::before, ::after { margin: 0; padding: 0; border:
0 solid }), so with Bootstrap ranked below it, preflight wiped Bootstrap's
own styling everywhere, not just at points of conflict. Caught via a cheap
getComputedStyle sanity check before running the full test suite.
Sandwiching bootstrap between Tailwind's base and utilities fixes
this: Bootstrap's own styling now outranks Tailwind's preflight, while
still losing to Tailwind's utilities for ordinary, normal-priority rules.
What layering does NOT fix: Bootstrap 5's utility-API classes
(.border, .bg-transparent) are generated with !important by default
— confirmed via getPropertyPriority() on the live CSSOM rule.
!important declarations sit in a completely separate priority tier from
normal-priority layered rules and always win regardless of which layer
either side is in, so no amount of layer reordering makes Tailwind's plain
border/bg-transparent beat Bootstrap's same-named class. packages/ui/src/BaseButton.tsx
uses differently-named classes (border-[1px], bg-[transparent]) for
exactly those two utilities, specifically because they collide with a
Bootstrap !important class — every other utility didn't need that
treatment once cascade layers were in place.
Root font-size scaling
Metronic forces html, body { font-size: 13px !important } (12px below the
lg breakpoint). Tailwind's default scale is rem-based, so on any page
where Metronic is also loaded, every rem-based Tailwind utility silently
resizes against a 13px root instead of the 16px Tailwind assumes — found
via BaseButtonTw's first visual-parity failure (a "primary-lg" button
rendered 40px tall instead of 44px). Cascade layering doesn't fix this — it
only decides which declaration wins, not what a rem-relative value computes
to once it does — so src/tailwind.css's @theme block overrides
--spacing/--text-sm/--text-base/--radius-md/--radius-lg with
explicit px values instead.
Brand color token bridge
src/tailwind.css's second @theme block points Tailwind's color
utilities at the CSS custom properties the app already sets at runtime
(--waldur-brand-*, written once at real app bootstrap by
afterBootstrap.tsx's initCssVariables(), itself called from
core/bootstrap.ts's loadConfig()). It doesn't define new colors — it's
purely a naming bridge, so BRAND_COLOR changes or dark-mode toggles
re-theme Tailwind utilities with zero code here changing.
Harnesses that bypass real app bootstrap must seed this manually, or
brand-reactive styles fall back incorrectly. Both SpikePage.tsx and
mountParity.tsx call generateBrandColors()/hexToRgb() (the same
functions afterBootstrap.tsx calls) to seed --waldur-brand-* on mount.
mountParity.tsx's harness in particular surfaced this the hard way: with
the var unset, .btn.btn-secondary (old Bootstrap) and var(--waldur-brand-*)
(new Tailwind) each hit CSS's own "invalid at computed-value time"
fallback differently (border-color resets to currentColor, text color
resets to inherited), producing two different wrong colors that had
nothing to do with either component implementation — the parity test's
first secondary-variant failure, misdiagnosed at first as a component
bug before being traced to the harness.
Live re-theming caveat (investigated in SpikePage.tsx, not a
migration blocker): rapidly repeated setProperty() calls on
--waldur-brand-* — as its color picker does on every keystroke —
occasionally left a dark:bg-brand-* utility rendering a stale color in
one browser session. A minimal non-Tailwind repro (double var()
indirection, no React) did not reproduce it, and most of the discrepancy
traced to normal oklch round-trip rounding rather than genuine staleness.
Moot either way — initCssVariables() only ever runs once, at bootstrap;
the real app has never supported live, reload-free BRAND_COLOR
propagation for Bootstrap-styled components either. Only worth a
from-scratch repro if a later phase deliberately adds live re-theming as a
new capability.
Dark mode signal
The real app toggles dark mode by swapping the entire compiled stylesheet
(src/theme/utils.ts's loadTheme(), via hrefs.dark/hrefs.light) —
there's no .dark class. loadTheme() now also sets data-theme on
<html>, purely as an additional signal for consumers that can't read
ThemeContext — Tailwind's dark: variant is defined in tailwind.css as
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *)).
The stylesheet swap remains the source of truth for existing
Bootstrap/Metronic styling; this is additive, not a second toggle.
BaseButtonTw color tokens
packages/design-tokens/src/colors.css and buttonColors.css hold the base
ramps and semantic button tokens. Cross-checked two ways: empirically via
getComputedStyle() on the real BaseButton in both themes, and against
Waldur's own reference design system (an Untitled-UI-derived Figma
library). The two agreed almost everywhere except the success/green ramp,
which Waldur customized away from the reference's stock green — plausibly
to stay visually distinct from the (also green) default brand color.
Where they disagreed, the empirically-extracted value won, since that's
what actually ships.
text-primary's color, wrong twice in opposite directions: the
original implementation used var(--waldur-brand-700) (correct); it was
"corrected" to var(--color-gray-900) during the reference cross-check, on
the strength of a stale getComputedStyle() read from a long-lived
interactive browser session (light and dark share one property name, so a
matched-properties-cache staleness issue silently returned the wrong
theme's value); a user visually spotted the resulting green-vs-gray
mismatch on text-primary-lg, which is what caught it. Re-verified with
genuinely fresh page loads in both themes before trusting either reading
again: light mode is brand-reactive (--waldur-brand-700), dark mode is a
fixed gray (--color-gray-300) — a real, legitimate asymmetry, not a bug.
Lesson: never trust a getComputedStyle() read from a long-lived
browser session when cross-checking theme-dependent CSS variables; always
reload fresh first.
Hover/focus states had real, separate token bugs, found by extending
the dominant-color parity check (below) to hover/focus-visible states and
comparing each case against the real button's getComputedStyle() output
and against _variables.custom.scss's $button-variants map (the actual
SCSS source of truth):
- Hover backgrounds for
secondary/danger/warning/successwere one token step too light in light mode (e.g.error-100where the real button useserror-200) and one step too dark in dark mode (e.g.error-800where the real button useserror-700) — verified per color family independently viagetComputedStyle(), not assumed uniform from one fix. primary/secondary/tertiary/tertiary-ghostnever changed their fill on focus-visible (stayed at the enabled color); the real button uses the same fill for hover and focus in all four of these.text-danger/text-warning/text-successnever changed their text color on hover/focus — the real button darkens/lightens it to the same interactive-state color its solid counterpart already uses (--btn-danger-textetc. — no new tokens needed, just reused). Their hover background also referenced the wrong token (the solid variant's enabled bg instead of its hover bg).danger/warning/successkept a visible border color during focus where the real button blends the border into the focus fill (the SCSS'focused'map omits an explicit border, which falls back to the focus background itself viabutton-custom-variant-2'sget-defhelper).warningadditionally blends its border on hover too, unlike danger/success — its'hovered'map omits border where theirs doesn't; confirmed viagetComputedStyle(), not assumed symmetric.secondary's dark-mode focus ring usedbrand-400; the real button's ring (read directly off the livebox-shadow) isbrand-500. The static SCSS token ($ring-primary-100→$border-brand-solid→$brand-400in dark mode) doesn't reliably track the runtime-injected--waldur-brand-*ramp used everywhere else — trust the live measurement over the SCSS source when the two disagree, since the live measurement is what actually ships.
--btn-text-secondary-hover-bg is a genuinely separate token from
--btn-tertiary-bg-hover, even though the two happen to share a value in
dark mode — the real button's secondary text-style hover/focus resolves
to $bg-tertiary (gray-100/gray-800), a distinct SCSS token from
tertiary's own $btn-tertiary-bg_hover (gray-50 light / gray-900
dark). Confirmed via getComputedStyle(), not assumed from the naming
similarity.
colors.css's --color-success-200 is a known, accepted small
inaccuracy (#a9efc5 vs. a measured #a6f4c5) — the ramp's own header
already flags success as partly interpolated and not independently
verified. The discrepancy's chromaticity delta (~1.8) is well inside the
established noise tolerance (see below), so it was left alone rather than
touched — changing a shared base-ramp value risks shifting other, already-
passing consumers (--btn-success-bg-pressed, dark --btn-text-success-color).
Visual parity test methodology (base-button-parity.spec.ts)
Screenshots BaseButton and BaseButtonTw side by side on the same page
— a Storybook story, Migration/BaseButton Parity (see "Storybook
toolchain" below) — and diffs the buffers directly — no committed baseline
image to go stale, since it's a live A/B comparison in the same browser,
same run. Covers all 12 variants × 2 sizes × 2 themes × 4 states
(enabled/disabled/hover/focus-visible) = 192 cases.
Two complementary checks, two different blind spots
Pixelmatch ratio (DIFF_RATIO_THRESHOLD, currently 16%): started at
2%, raised as measurement (not guessing) showed the real noise ceiling was
higher — up to ~12% on text-only/pastel-background variants, where
antialiasing-affected edge pixels are a much larger fraction of a small or
low-contrast button's total area than on a solid-fill button. Confirmed as
pure rendering noise (not real bugs) by checking getComputedStyle() and
dimensions byte-identical in every case that triggered it, before touching
the threshold. Measured post-fix ceiling (2026, after the hover/focus
token fixes above): 11.72%, on pastel-background lg buttons at
enabled (secondary-lg, success-lg). Re-measured again after the
harness moved to Storybook (same DOM-wrapper-chain reasoning as the
chromaticity recalibration below): 13.10% — leaves ~3 points of margin
under the current 16% threshold, still comfortably inside it.
Blind spot: a small text-only button is almost all background: a
completely wrong hue only touches a small fraction of the image and reads
as a low ratio no threshold tuning fixes without also hiding real
antialiasing noise. This is exactly how the original text-primary
green-vs-gray bug (see above) slipped through at a 6.55% ratio — well
under threshold — and was only caught by a human looking at the page after
the full 192-case suite had reported clean.
Dominant-color chromaticity check (CHROMATICITY_TOLERANCE, currently
10 — see the recalibration note below for why it moved from 5): built
specifically to close that blind spot. Averages RGB over
"foreground" pixels — anything beyond FOREGROUND_DISTANCE_THRESHOLD from
the button's own corner-background color, isolating glyph/border/fill
regardless of the button's own transparency. (First version filtered by
alpha > 200, on the wrong assumption that a bg-transparent button would
screenshot as low-alpha pixels — Playwright's element screenshots are
always fully opaque, confirmed via a real alpha histogram, so that filter
silently matched the entire image and let the bug back through
undetected.) Compares old vs. new not on raw RGB but on chromaticity
— each channel's share of total brightness (r,g,b / (r+g+b), scaled back
to 0–255) — which cancels out uniform lighter/darker shifts (antialiasing
noise) while staying sensitive to an actual hue change (a real token bug).
Calibrated empirically: forcing the check to report (tolerance 0) across
all 192 cases on the corrected codebase measured a max chromaticity
delta of 5, with 133/192 cases at exactly 0. The two outliers at 5 are
both sm-size tertiary-ghost/text-secondary focus-ring cases with
byte-identical getComputedStyle() output on both sides — the same class
of small-radius box-shadow corner-rendering artifact documented below, not
a token bug. Raw (non-normalized) per-channel delta was tried first and
rejected: it ranged 1–43 depending on variant/theme purely from legitimate
antialiasing noise, too wide a range to set a single global tolerance
against, since the real text-primary bug this check exists to catch only
measured ~33 in its own variant — a tolerance high enough to avoid
flagging legitimate noise would have sat above the bug's own signal and
missed it. That original tolerance (5) sat with effectively zero margin above the
measured noise ceiling — deliberate at the time, since the ceiling was a
real, verified rendering-engine limit.
Recalibrated to 10 after moving the harness to Storybook: swapping the
raw ?tw-parity route for a Storybook story (see "Storybook toolchain"
below) changes the DOM wrapper chain around the buttons, which shifts
sub-pixel rendering slightly even though the components/tokens themselves
are unchanged — the old tolerance (5) started flaking. Recalibrated
properly, not just bumped: ran the full 192-case suite three separate
times (two against an already-warm Storybook dev server, one against a
genuinely cold one — the scenario CI always hits), and all three
converged on an identical max chromaticity delta of 4.61. A single
ad hoc diagnostic script (used to investigate one flaking case in
isolation, outside the real suite) had shown 5.567 for that same case;
re-run through the actual spec three times and it never reproduced —
confirmed as an artifact of that throwaway script, not the suite itself,
before trusting either number. Tolerance set to 10: just over 2x margin
above a three-times-reproduced stable ceiling, while staying clearly below
the confirmed real-bug signal (~17) so the check stays useful rather than
just quiet.
Known, accepted rendering-engine limitations
Both checks converge on the same conclusion: token/color values are now
verified byte-identical between old and new (via direct
getComputedStyle() comparison) everywhere the test suite still shows any
residual diff. What remains is not fixable at the token/component
level — it's inherent to comparing two structurally different CSS
implementations (Bootstrap vs. Tailwind) achieving the same visual design:
- Box-shadow corner rendering at small radius: the real
BaseButton's focus ring measures a2.015pxspread;BaseButtonTw's measures exactly2px— same color, samebox-shadowsyntax on both sides, but Chromium computes a slightly different final spread depending on each implementation's intermediate box-sizing chain (remvs.pxunits, different wrapper elements). At small corner radii (sm-size buttons) this shows up as measurably different antialiasing coverage on the ring itself. Originally found and accepted fordanger-sm/warning-sm(15.16%/15.35% ratio, confirmed stable across a full server restart); the same phenomenon later showed up ontertiary-ghost/text-secondary's gray ring in the chromaticity check. - Text/edge antialiasing on pastel backgrounds at
lgsize: text antialiasing coverage depends on exactly which sub-pixel position a glyph lands on, which depends on the precise padding/line-height chain — even when both resolve to the same final px value on paper, the browser can round differently building up to it.
The only way to eliminate these fully would be literally sharing DOM
structure and CSS between old and new, which defeats the point of the
migration. The right response, applied throughout this file's threshold
history, is to measure the real noise ceiling empirically and set
tolerances with deliberate, documented margin above it — not to guess, and
not to loosen a check just because a state is hard to get pixel-perfect
without first confirming (via getComputedStyle(), not assumption) that
what remains really is noise and not an unfixed bug. Every threshold in
this file was set that way; see git history / this document's revision
history for the evidence behind any specific number.
Other test-harness lessons
- CSS transitions need a real paint to settle: a synchronous
getComputedStyle()read immediately after.hover()/.focus()— with no repaint in between — reliably returns the pre-transition value, not the settled one (.screenshot()forces a real paint, which is why a transition-timing bug only ever showed up as a screenshot mismatch, never in agetComputedStyle()-based diagnostic during triage).gotoParity()disables all transitions/animations page-wide via an injected stylesheet specifically to remove this as a variable; the explicitwaitForTimeout(350)before each hover/focus screenshot is a second, independent safeguard. :focus-visibleisn't reliably triggered by a raw DOM.focus()call in Chromium — it requires the focus to plausibly originate from keyboard interaction. A quickelement.focus()diagnostic can read backbox-shadow: none/outline-style: noneon a button that visibly does show a ring under real interaction, misleadingly suggesting the ring isn't implemented at all. Playwright'slocator.focus()(used by the actual test suite) does trigger it correctly; verify with that, not a bare.focus()in a scratch script.- Run visual suites with
--workers=1on this machine — a full 192-case run at--workers=3exhausted available RAM. Slower (~13-15 min vs. ~12 min) but reliable.
Storybook toolchain
yarn storybook (dev, port 6006) / yarn build-storybook. Replaced both
hand-rolled harnesses that used to live under src/spike/tailwind/
(SpikePage.tsx/Button.tsx/mount.tsx for the ?tw-spike showcase, and
ParityPage.tsx/mountParity.tsx for the ?tw-parity old-vs-new
comparison — the whole directory is gone) with real stories:
BaseButtonTw.stories.tsx and BaseButton.stories.tsx, one story per
state (enabled/disabled/hover/focus-visible) rendering the full
variant × size matrix, plus BaseButtonParity.stories.tsx for the
old-vs-new comparison the Playwright spec needs (see below — it's a
genuinely different kind of story from the other two).
.storybook/main.ts's viteFinal hand-duplicates vite.config.ts's
resolve.alias/css.preprocessorOptions/define, and adds
@tailwindcss/vite fresh, rather than reusing vite.config.ts's plugins
array wholesale — that array registers its own react(), which would
double up with @storybook/react-vite's own React handling. Keep the
duplicated values in sync by hand; they change rarely.
.storybook/preview.tsx's theme toggle calls loadTheme()
(src/theme/utils.ts) directly — the same function the real app's
ThemeProvider calls — rather than reimplementing a Tailwind-only
data-theme toggle. loadTheme() both swaps the compiled Metronic
stylesheet AND sets data-theme, so BaseButton.stories.tsx (old,
Bootstrap-only, no dark: variant of its own) gets correctly-themed CSS
too, not just BaseButtonTw's Tailwind dark: variant. First navigation
to a dark story can render briefly unstyled while Vite cold-compiles the
(large) dark SCSS bundle — same class of flash gotoParity() already
works around in the Playwright spec; not a bug in the decorator.
Hover/focus-visible without real interaction:
storybook-addon-pseudo-states rewrites matching stylesheet rules (via
CSSOM insertRule, not by touching <style> text — worth knowing if you
ever need to debug it by grepping stylesheet content, since a plain-text
search finds nothing despite the rule being live) to add a class-based
selector alternative, then toggles that class on #storybook-root. This
sidesteps the exact fragility this migration hit repeatedly with
Playwright: a raw DOM .focus()/.hover() doesn't reliably settle a CSS
transition before the next read, and :focus-visible specifically isn't
triggered by a synchronous .focus() call at all in Chromium (see below).
Confirmed working end-to-end via a real Playwright screenshot — see the
next note for why that verification method mattered here specifically.
A debugging trap worth recording: while first verifying the
pseudo-states addon, an ad-hoc in-session browser-automation tool reported
getComputedStyle() not reflecting even an inline !important style set
directly via JS — which is close to impossible under normal CSS cascade
rules. That tool's own screenshot call failed with "the page is not
compositing frames," which was the real signal: the tool's page wasn't
actually being composited/rendered, so its computed-style reads for
certain properties (background-color; outline read back correctly,
oddly) were stale/unreliable, not the page's CSS. Cross-checking with a
throwaway Playwright script (matching this whole file's established
"trust the properly-rendered, headless-but-composited browser, not an ad
hoc automation tool" pattern) immediately showed the real, correct value.
Lesson: when a check produces a logically-impossible result (a
highest-priority CSS declaration not applying), suspect the verification
tool before the code under test — especially one that just told you it
isn't rendering frames.
The parity harness lives in Storybook too, but as a different kind of
story: BaseButtonParity.stories.tsx (Migration/BaseButton Parity in
the sidebar) renders old and new side by side, tagged
data-pair/data-role, replacing the old raw ?tw-parity route
(ParityPage.tsx/mountParity.tsx/the branch in src/index.tsx — all
removed; index.tsx is back to unconditionally rendering <Application
/>, with zero Tailwind-migration footprint in the real app's entry
point). base-button-parity.spec.ts's gotoParity() navigates straight
to that story's iframe.html URL (http://localhost:6006/iframe.html?
id=migration-basebutton-parity--default&viewMode=story&globals=theme:
{theme}) — a second webServer entry in playwright.config.ts starts
Storybook's dev server alongside the main app's.
This is deliberately a different kind of story from
BaseButton.stories.tsx/BaseButtonTw.stories.tsx: those use
storybook-addon-pseudo-states to force hover/focus-visible via a CSS
class, which is right for a quick visual browse but wouldn't exercise the
real thing this spec needs to catch bugs in — the spec still drives real
Playwright .hover()/.focus() against the rendered pairs itself, exactly
as it did against the old ?tw-parity route. Once BaseButtonTw
graduates to replace BaseButton outright, this parity story and spec
become unnecessary and the plain component stories become the sole,
permanent home for verifying this (and future migrated) components.
Vitest project split (vitest.config.ts): adding
@storybook/addon-vitest (via yarn dlx storybook@latest add
@storybook/addon-vitest, which rewrites this file — don't hand-edit
around it) restructured the previously-flat Vitest config into two named
projects: unit (the original jsdom-based unit tests, unchanged) and
storybook (browser-mode, real Chromium via @vitest/browser-playwright,
renders every story as a smoke test). They need different CI images — the
storybook project needs the same Playwright-browser image the E2E jobs
use, which a plain Node image can't run — so .gitlab-ci.yml's "Run unit
tests" job now explicitly passes --project=unit to avoid accidentally
picking up the browser-mode project on an image that can't run it, and
"Run Storybook tests" is a separate job on the Playwright image.
storybook project's optimizeDeps.include: without it, every run
fails at import time with a browser-native SyntaxError: ... does not
provide an export named 'X' (or, once that specific package is listed,
ReferenceError: exports is not defined for the next one) — a CJS/ESM
interop failure. Root cause: Vite's dependency scanner only discovers
which node_modules packages need its esbuild pre-bundle pass by crawling
from project source files; it can't see into
@storybook/addon-vitest/dist/vitest-plugin/setup-file-with-project-annotations.js
(a Storybook-internal build artifact, not project source), so none of
that file's transitive CJS deps get pre-bundled and each hits the same
interop failure when served on-demand instead. Tried pointing
optimizeDeps.entries at the setup file directly first, hoping the
scanner would trace through it — it didn't (and pointing entries at
.storybook/**/* more broadly crashed differently, pulling in
main.ts's @tailwindcss/vite, which loads a native .node binary
esbuild can't bundle at all). Explicitly listing each package in
optimizeDeps.include instead — aria-query, then lz-string, then
pretty-format, found one at a time as each fix revealed the next
unbundled dep in the same setup file's chain — worked and was stable
across repeated runs. Disabling the a11y integration
(parameters.a11y.test = 'off' in preview.tsx) was tried too, on the
theory that a11y assertions were what pulled in the jest-dom-adjacent
chain — didn't help, so the dependency is unconditional in the setup file,
not a11y-specific.
packages/ui: portable Tailwind/Radix primitives
packages/ui (waldur-ui) holds the pieces of BaseButtonTw's
dependency graph that are genuinely portable, extracted out one at a time
as each stopped depending on Bootstrap:
cn()— had zero Bootstrap coupling to begin with, just moved.LoadingSpinner— swaps Metronic's.animation-spinclass for Tailwind's built-inanimate-spinutility, and drops the.text-primaryBootstrap-utility default sinceSpinnerIconalready renders withfill="currentColor"— no color class was ever actually needed. The originalLoadingSpinnerSimple(src/core/LoadingSpinner.tsx) is untouched and still used by ~385 call sites elsewhere.Tooltip— a@radix-ui/react-tooltip-based rebuild ofsrc/core/Tooltip.tsx'sTip(react-bootstrap'sOverlayTrigger), built for visual parity with it, not just a generic shadcn tooltip. Scoped toTip's actual current usage —label+ optionalbody, default hover/focus trigger — not its fuller react-bootstrap-derived API (triggerarrays,container,rootClose,delay,autoWidth,zIndex, light/darktheme), since nothing inpackages/uineeds that yet.Tipitself always defaults to its dark-bubble theme regardless of the app's light/dark mode —BaseButtonTw's own usage never passestheme, so only that one variant needed replicating.
Tooltip visual-parity cross-check: every value in Tooltip.tsx's class
list (background/text colors, padding, border-radius, box-shadow,
font-size/line-height, max-width, arrow size) was extracted via
getComputedStyle() on the real Tip rendered in Storybook — same
methodology as BaseButtonTw's own tokens (see above), using a throwaway
probe story rendered by a headless Playwright script rather than the
Claude Code browser tool directly, for the same computed-style-reliability
reason noted elsewhere in this doc. Result was near-pixel-identical: bg
rgb(16,24,40) (exactly --color-gray-900), body text
rgb(208,213,221) (exactly --color-gray-300), border-radius exactly
8px, padding/font-size/line-height within sub-pixel rounding
(12.012px→12px, 17.1957px→17px). The color match isn't coincidence —
Tip's dark bubble already happened to use exactly this app's gray-900/
gray-300 ramp steps, so the new component references
packages/design-tokens' tokens directly rather than duplicating the hex
values.
Numbered Tailwind spacing utilities (p-3, mt-1) don't reliably pick
up this file's --spacing: 4px override: measured 9.75px for p-3
where 12px was expected (9.75 = 3 × 0.25rem × 13px — the default
rem-based Tailwind spacing scale at Metronic's forced 13px root, not the
override). Root-caused to cascade-layer precedence: tailwind.css
declares @layer theme, base, bootstrap, utilities, and Tailwind's own
compiled utilities apparently carry their own --spacing declaration
inside the (higher-priority) utilities layer, which wins over the
custom override living in the theme layer. --radius-lg/--text-sm
aren't affected since those are directly-named theme keys with no
downstream calc() multiplication — only the base --spacing multiplier
is shadowed. Fix: use px arbitrary values (p-[12px], mt-[4px])
instead of the numbered scale, exactly what BaseButtonTw already does
for all of its own padding — this was already a deliberate, established
pattern in this codebase, and the tooltip work is independent confirmation
of why.
BaseButtonTw has moved into packages/ui (packages/ui/src/BaseButton.tsx,
exported as BaseButton — the Tw suffix was only ever needed to
disambiguate from the Bootstrap original within src/core/buttons/; once
moved into its own package there's nothing to disambiguate from, matching
the unprefixed naming already used for LoadingSpinner/Tooltip). Its
own internal imports of cn/LoadingSpinner/Tooltip became relative
(./cn etc.) instead of round-tripping through the waldur-ui package
name. waldur-homeport/src/core/buttons/BaseButtonParity.stories.tsx
(the old-vs-new comparison fixture the Playwright spec screenshots) is
the one place that still needs both components side by side — it imports
the new one as import { BaseButton as BaseButtonTw } from 'waldur-ui',
aliasing back to the old name locally purely for that file's own
readability. BaseButtonTw.stories.tsx moved too
(packages/ui/src/BaseButton.stories.tsx), keeping its Storybook sidebar
title (Core/Buttons/BaseButtonTw) unchanged on purpose so it still
appears right next to the Bootstrap original's story for side-by-side
browsing — sidebar grouping is independent of where the file physically
lives. Full 192-case parity suite re-verified passing after the move.
packages/ui now has everything BaseButtonTw needs — cn,
LoadingSpinner, Tooltip, and the button itself — with zero remaining
Bootstrap coupling.
Disabled buttons leaking their hover color under Storybook's forced
pseudo-states: six variants with a transparent base background
(tertiary-ghost, text-primary, text-secondary, text-danger,
text-warning, text-success) had no disabled:bg- override at all —
relying solely on disabled:pointer-events-none to make sure a real
:hover could never fire on a disabled button. That's true for every real
browser interaction, but Storybook's storybook-addon-pseudo-states
toolbar can force :hover/:focus-visible/etc. on any story via a
class-selector rewrite that doesn't respect pointer-events: none —
toggling it on the Disabled story showed a disabled text-warning
button with --btn-warning-bg-hover (#fedf89) as its background instead
of staying transparent. Confirmed this wasn't a pre-existing addon quirk
by checking the real Bootstrap BaseButton under the identical forced
toggle — it stays transparent, so this was a genuine gap in the new
class list, not an artifact everything already lived with. Fixed by
adding an explicit disabled:bg-[transparent] to all six variants (the
solid variants — primary/secondary/tertiary/danger/warning/
success — already had an equivalent disabled:bg-[var(--btn-disabled-bg)]
override, so they were never affected). Structurally can't manifest for a
real end user, but worth fixing so the class list is self-consistently
correct regardless of what triggers the pseudo-state, not just what a real
mouse can trigger. Full 192-case parity suite re-verified passing —
CSS-additive fix, doesn't touch anything the suite's enabled/disabled/
hover/focus-visible states actually screenshot.
tertiary's border looked visibly thicker than the real Bootstrap
button's, despite getComputedStyle() showing byte-identical
border-width/border-color (1px/rgb(208,213,221)) on both. Root
cause: border participates in the box's total height under
box-sizing: border-box, and the real button's rem-based padding/
line-height chain lands on a fractional total height at this app's 13px
root font-size (43.96875px for lg), while this component's explicit
px values land on an exact integer (44px) — deliberately, per the root
font-size scaling section above. A 1px border around a fractional-height
box gets anti-aliased across the sub-pixel boundary and reads softer/
thinner; the same border around an exact-integer box renders crisp and
full-opacity, which reads as visibly thicker by comparison even though
the declared width/color are identical numbers. Confirmed via screenshots
at 3x device-scale-factor (the artifact is subtle at 1x, obvious at 3x —
plausibly why it surfaced on a high-DPI display).
Fixed by replacing border with an inset box-shadow for the
border-replacement across all 12 variants (shadow-[inset_0_0_0_1px_<color>],
transparent for the six variants with no visible border) —
box-shadow doesn't participate in layout sizing at all, sidestepping
the fractional-vs-integer anti-aliasing difference entirely. Required two
compensating changes:
- Padding: since box-shadow no longer consumes the 2px (1px × 2
sides) a real border used to eat from the padding-box, both
sizevariants gained 1px of padding per side (py-[3px]→py-[4px]forsm,py-[9px]→py-[10px]forlg) to keep the exact same total heights (28px/44px) — padding is a shared axis across all 12 color variants viacva, so this had to change once at thesizelevel rather than per-variant. focus-visiblecombining:box-shadowis one CSS property, so afocus-visible:shadow-[...]class fully replaces the property rather than layering on top of the baseshadow-[inset_...]the way a realborder(a separate property frombox-shadow) naturally coexisted with the oldfocus-visible:shadow-[0_0_0_2px_var(--btn-*-focus-ring)]ring. The five variants with a real border (secondary/tertiary/danger/warning/success) needed theirfocus-visible:shadow-[...]rewritten to include both layers in one bracket value (inset border + outer ring, comma-separated) so the border doesn't visibly vanish while focused. Same combining need forwarning's hover-only border blend (hover:border-[...]→hover:shadow-[inset_0_0_0_1px_...], no ring involved there). The seven variants with no visible border (primary,tertiary-ghost, and the fivetext-*ones) didn't need combining — their border was already transparent, so losing it during focus made no visible difference either way, matching their pre-fix behavior exactly.
Verified: border-width: 0px and the expected inset-shadow color on all
of primary/secondary/tertiary/danger/warning/success/
text-warning via getComputedStyle() (confirming the property swap
took effect and every color value survived unchanged), screenshots
showing matching border crispness against the real button at 3x scale,
and the full 192-case parity suite still passing.
That box-shadow border swap broke the hover transition's smoothness,
reported as "hover doesn't work" — real mouse hover did still change
every color correctly (verified via getComputedStyle() before/after a
real Playwright .hover(), both for genuine interaction and for
Storybook's forced-pseudo-state Hover story), but it didn't look
like it worked: Tailwind's transition-colors utility's
transition-property list is color, background-color, border-color,
outline-color, text-decoration-color, fill, stroke, ... — no
box-shadow. With the border now living in box-shadow instead of
border-color, the background eased in smoothly over the existing
150ms while the border-replacement snapped instantly, a jarring,
half-animated effect on every variant with a visible border. Fixed by
replacing transition-colors with an explicit
transition-[color,background-color,box-shadow]. Confirmed via
getComputedStyle().transitionProperty before/after; the parity
spec's screenshots are unaffected either way since it explicitly
disables all transitions/animations before capturing (see the parity
methodology section above), so this was invisible to the 192-case
suite in both directions — a case where the automated check couldn't
have caught the regression at all, only interactive/visual testing
could.
Reflowed buttonVariants's per-variant class strings from one
dense 200-400 character line each into an array of per-state fragments
([base-shadow, base-bg/text, hover, focus-visible, active, disabled],
joined with .join(' ')) — same literal class tokens Tailwind's
scanner already saw, purely a formatting change, no value/behavior
difference. Motivated directly by the three bugs above: all three were
subtle mistakes buried mid-string in a format that made them hard to
spot in review (a missing disabled: line, a focus-visible:shadow
needing to combine two things into one value, a base-string property
list missing an entry) — splitting each state onto its own line makes
each of those the kind of thing you can eyeball against its neighbors.
Verified as a true no-op via the full 192-case parity suite (still
192/192) and a build-storybook run, not just tsc/eslint.