Theming guide
Re-skin the whole framework by remapping the semantic token tier in your own unlayered stylesheet — the cascade contract guarantees it wins. Never override component classes; never touch the raw palette tier. Ready-made palettes ship — see the Palettes reference.
The contract
- Remap semantic tokens only (
--bo-color-*, fonts, radii). Component-local tokens (--bo-btn-*) are for per-instance tweaks, not brand. - Keep the token roles honest:
*-solidcarries white text in both themes (≥ 4.5:1);*-textsits on*-subtle(≥ 4.5:1); dark mode remaps*-text/*-subtle, never*-solid. - Verify contrast when you swap hues — the roles encode the ratios, your values must honor them.
- The dark rule: unlayered
:rootbeats the framework's layered dark remaps — every token you set must be re-set in your own[data-theme="dark"]block if dark differs. - An accent swap is a family swap:
accent,accent-strong,accent-solid,accent-subtle,accent-text, plusfocus-ringandbg-selected— miss one and the UI is two-toned.
brand.css
/* brand.css — imported AFTER the framework, outside any @layer,
so it wins by the cascade contract. Re-skin by remapping the SEMANTIC
tier only — never touch component files.
THE DARK RULE: an unlayered :root declaration beats the framework's
LAYERED [data-theme="dark"] remap of the same token. So every token you
set on :root must be re-set in your own dark block if dark differs —
otherwise your light value leaks into dark mode. */
:root {
--bo-color-accent: #0f766e; /* your brand teal */
--bo-color-accent-strong: #115e59;
--bo-color-accent-solid: #0f766e; /* must keep >= 4.5:1 under white text */
--bo-color-accent-subtle: #f0fdfa;
--bo-color-accent-text: #115e59; /* >= 4.5:1 on accent-subtle */
/* A full accent swap also includes the accent-adjacent tokens the
framework colors blue by default: */
--bo-color-focus-ring: #0d9488; /* else focus rings stay blue */
--bo-color-bg-selected: #f0fdfa; /* else selected rows stay blue */
--bo-font-sans: "Your Corporate Sans", system-ui, sans-serif;
--bo-radius-md: 0.25rem; /* sharper corporate corners */
}
[data-theme="dark"] {
--bo-color-accent: #14b8a6; /* REQUIRED: re-set every :root token */
--bo-color-accent-text: #5eead4; /* light step for dark surfaces */
--bo-color-accent-subtle: #134e4a;
--bo-color-bg-selected: #134e4a;
} A real, shipped preset — @busy-office/ui/css/brand-indigo
Six presets ship, each aimed at a real ERP brand profile: graphite
(monochrome), cobalt (enterprise blue), navy (deep conservative blue),
forest (green), indigo, and violet — browse and compare them on the
Palettes page. Indigo below is one of them: not just this page's prose
example — an actual importable file
(packages/core/src/css/brand/brand-indigo.css), validated by the
same contrast gate as the base theme (scripts/check-contrast.mjs
now checks every file in src/css/brand/, not asserted by hand).
The live preview below applies the identical token values scoped to just this
box (for a real app, they'd go on :root as shown above — this
page keeps its own default teal everywhere else on purpose):
Switching themes at runtime
Flip data-theme on the root. One catch: a component's
background-color transition animates on the switch while the text
color snaps instantly, so text can flash on a still-light background
(near-invisible going to dark). Freeze transitions for the one frame of the flip
with data-bo-theming (shipped in the utilities layer):
function setTheme(value) {
const html = document.documentElement;
html.setAttribute('data-bo-theming', ''); // freeze transitions
html.dataset.theme = value;
requestAnimationFrame(() =>
requestAnimationFrame(() => html.removeAttribute('data-bo-theming')));
} Two frames: one to paint the new theme with transitions off, the next to re-enable them. Hover/focus transitions are unaffected.
Versioning policy
Semantic tokens, documented class names, and data-*/ARIA contracts
are the public API — breaking any of them is a major version. Component-internal
selectors and the raw palette are not API. Per-component dist file
placement is explicitly not API until v1.0 — granular importers should
pin versions; moves are recorded in the CHANGELOG. New components/tokens are
minors; visual-only tweaks within the same contracts are patches.