Troubleshooting
This framework's failure modes are unusually silent — modern CSS degrades by doing nothing, not by erroring. Symptom → cause:
"My markup looks right but nothing happens"
Usually a class or an attribute value that does not exist. Both fail silently: an unknown class leaves the element unstyled and looks like a CSS bug, and an unknown attribute value is worse — the attribute is present, spelled correctly, and does nothing at all.
npx bo-check-markup dist # or any directory of built HTML It checks your HTML against api.json, which is extracted
from the shipped CSS and so cannot disagree with it: every
bo-* class must exist, and every framework
data-* attribute must carry a value the CSS actually switches
on. It reports the file and suggests the nearest real name.
Your own data-* hooks are never
flagged — data-row-id and the like belong to your app, and
nothing distinguishes them from a misspelled framework attribute, so the
tool stays quiet rather than noisy. This repo runs it over its own docs on
every build; it found an invented .bo-composer__input and a
.bo-u-text-sm that never existed.
Better than reaching for it once something is already wrong: wire it into your build. It needs built HTML, so it runs after your build — and it checks that names exist, not that your markup means anything.
| Symptom | Cause & fix |
|---|---|
| Dialogs / tabs / dropdowns do nothing | The behavior init was never called. initDialogs(), initTabs(), initDropdowns() — once each, anywhere, any time. See JS behaviors. |
| Components render unstyled or half-styled à la carte | Import order: tokens, then reset, then components. Components carry only their own layer. |
| Your overrides don't apply | They're inside an @layer. Write app CSS unlayered — unlayered always wins. Never !important. |
| Form error messages never appear | Below the browser floor (:has()/:user-invalid), interactive reveals are fail-closed. Server-set aria-invalid + message-as-sibling still works. Floor: Chrome/Edge 119 · Firefox 129 · Safari 17.5. |
| Bulk-actions bar stale after an HTMX row swap | Only htmx:after:swap re-derives automatically. Other swap mechanisms: call refreshDataTable(container). |
| Everything is 40% too small | The host page sets html { font-size: 62.5% }. The framework assumes an unmodified root — embed via iframe. |
| Density attribute seems ignored on one table | It's on a wrapper while the table sits in a narrow container — explicit density wins only when set on the table or its container element itself. |
| Sort arrows render but clicking does nothing | By contract: your code re-orders rows and sets aria-sort; the CSS only draws the indicator. |
| Toast appears behind an open dialog | The dialog owns the browser top layer; z-index can't beat it. Render confirmations inside the dialog. |
| Checked rows POST nothing | Row checkboxes need name/value. |
| Buttons/tables go unstyled the moment Tailwind (or an old reset) is added | The reset is unlayered, and unlayered CSS beats every @layer — by design. Wrap it: see below. |
Using with Tailwind or an existing reset
All framework CSS lives in @layer, and your unlayered CSS
always wins — that's the override
story. The flip side: an unlayered reset you didn't write
(Tailwind v3 preflight, normalize.css, Bootstrap reboot, a CSS-in-JS
global) also wins, and silently strips components — preflight's
button { background: transparent } is the classic.
Wrap the reset in a layer that ranks below the framework's:
/* app.css — the ONE statement that matters comes first: it fixes the
layer order with your reset's layer ranked BELOW the framework's. */
@layer app-reset, bo-reset, bo-tokens, bo-primitives, bo-components, bo-utilities;
@import url("tailwind-or-normalize-or-reboot.css") layer(app-reset);
@import url("busy-office-ui.min.css"); /* or the npm import */
/* Your own overrides: write them UNLAYERED, as usual — unlayered still
beats everything, which is the framework's normal override story. */ The order statement must be the first
rule of the entry stylesheet — a layer's rank is fixed by where
it first appears, so declaring app-reset before the
framework's layers is what pins it underneath. Tailwind v4
declares its own layers and coexists without wrapping; on v3 you can also
just disable preflight (corePlugins: { preflight: false }).
Proof, live
app-reset: framework wins.Each frame is an isolated document running the
exact same hostile reset — the only difference is the one
@layer statement. (Frames pin a white background: they don't
inherit the docs theme.)