@webority/theme
v0.50.1
Published
Webority shared design system — Bootstrap 5.3 SCSS overrides, --wui-* tokens, and shared component CSS. The single source of brand chrome for React portals and Razor sites.
Readme
@webority/theme
Webority's internal design system — Bootstrap 5.3 SCSS overrides, --wui-* tokens, and
shared component CSS. Consumed by @webority/ui-react and Webority.Ui.Razor.
Published publicly so npm install needs no private registry. Built for Webority
products; no support commitment outside them.
Bias: thin layer on Bootstrap. Shared identity + control language + hard gaps — not a second framework and not every product's marketing site.
Use
Compile the partials with your brand values, in this order — the order is load-bearing:
@import "your-brand/variables"; // your $primary, fonts, etc.
@import "@webority/theme/scss/variables"; // library defaults (yours win)
@import "bootstrap/scss/bootstrap";
@import "@webority/theme/scss/tokens"; // --wui-* tokens
@import "@webority/theme/scss/wui-aliases"; // --color-* / --radius-* aliases
@import "@webority/theme/scss/components"; // portal / control chrome
@import "@webority/theme/scss/wui-components";
// Optional — marketing sites only (heroes, section-y, pill CTAs, reveal motion):
// @import "@webority/theme/scss/marketing";
// Optional, only if the site renders the phone input or country picker (see Country flags):
// @import "@webority/theme/scss/flags";Importing part of the core chain is a bug: skipping wui-aliases leaves every
--color-* reference unresolved.
The primary as text: --wui-primary-ink
A brand primary is chosen as a fill. A light one fails WCAG AA as text: an orange such as #E8501E
measures 3.75:1 on white. So the theme keeps two colours:
--wui-primary(--color-primary): fills, borders, focus rings, the selected-day disc.--wui-primary-ink(--color-primary-ink): every link, tab label, page number, selected option, avatar initial and primary-coloured icon. It is$primarydarkened in 2% steps until it clears 4.5:1 on white, the page, the muted and elevated fills and the brand's own soft tint, so it equals$primarywhen the primary already clears AA. Orange#E8501Egets#BA4018(5.48:1 on white).--wui-primary-text(--color-primary-text): the label ON a solid primary fill, white when white clears 4.5:1 on the primary, near-black otherwise.
All three derive from $primary at compile time, so setting $primary (or wui-brand-primary) is
enough. A product can set $wui-primary-ink / $wui-primary-text before the theme's variables, or
--wui-primary-ink / --wui-primary-text at runtime. A brand layer that re-points --wui-primary at
runtime must set --wui-primary-ink and --wui-primary-text beside it. Dark mode uses the lifted
--wui-dark-primary for the ink. check-contrast proves the derivation for an orange, an amber and a
sky test brand and fails any theme rule that colours text with --color-primary.
Country flags are opt-in
The 245 .wui-flag-* rules are about 187 KB raw (38 KB Brotli) of inlined SVG, so
wui-components does not include them. A site that renders AppPhoneNumberInput,
<app-phone-number-input>, AppCountrySelect or <app-country-select> adds them once:
- Your own SCSS:
@import "@webority/theme/scss/flags";afterwui-components. Import it once; legacy@importdoes not dedupe, so a second import doubles every flag rule. - Precompiled CSS: link
@webority/theme/css/flags(dist/webority-flags.css). The Razor package serves the same file at_content/Webority.Ui.Razor/css/webority-flags.css. - Per page: on a site with one phone field, link the flags stylesheet only on the pages that render it. Without it every flag is an empty grey box, and nothing reports the cause.
Purging unused CSS (PurgeCSS)
A consuming site's PurgeCSS pass reads its own pages, but Webority.Ui.Razor ships as a DLL and the
React and elements packages ship as bundles, so the markup they render at runtime is invisible to it.
@webority/theme therefore ships purge-safelist.json, generated by scripts/gen-purge-safelist.mjs
at theme build time from the tag helpers, custom elements, React components and the compiled CSS:
classes: every theme class the library's own markup names.patterns: regular-expression sources (^btn-,^wui-flag-, ...) for classes the library builds from a prefix at runtime, where the tail is only known in the browser.
A Razor site already installs @webority/theme for its SCSS build, so the file is in its node_modules.
Wire it into PurgeCSS (purgecss.config.cjs, run after sass and before the file is committed):
const { classes, patterns } = require("@webority/theme/purge-safelist.json");
module.exports = {
content: ["Pages/**/*.cshtml", "wwwroot/js/**/*.js"],
css: ["wwwroot/css/site.css"],
safelist: {
standard: [...classes, ...patterns.map((p) => new RegExp(p))],
// Bootstrap state classes and attribute selectors the library toggles at runtime.
greedy: [/^data-bs-/, /^wui-/],
},
};Add your own safelist entries for classes your page scripts add. PurgeCSS failures are silent, so compare every page before and after a purge.
$secondary is quiet grey (de-emphasised text/buttons), not a second brand
colour — intentional shared semantics (see _variables.scss).
dist/webority-theme.css is a precompiled default-brand portal build (no
marketing partial). Prefer the SCSS chain above for brand-exact output.
Signal brand layer (opt-in)
dist/webority-brand-signal.css (export @webority/theme/css/brand-signal, source scss/_brand-signal.scss)
re-points the theme to the Webority company palette: violet #4A0FBF primary, ink #16093A headings, tint
#F1EBFF, pink #B517C8 for unread counts only, muted text #6A6A6A, a dark set on pure black #000 with neutral
grey surfaces and violet #A98BFF kept for accents, and the Webority Serif 600 face for page, card, modal and empty-state titles. It is never imported by
the default theme (check-theme-exports asserts the default dist has no #4A0FBF).
Load it once, after the theme: React import "@webority/theme/css/brand-signal"; Razor
_content/Webority.Ui.Razor/css/webority-brand-signal.css; SCSS @import "@webority/theme/scss/brand-signal"
after the components. Dark mode is emitted under [data-theme=dark], [data-bs-theme=dark] and the
data-theme=auto media query. Contrast of every brand text and surface pair is held at 4.5:1 by check-contrast.
_brand-signal.scss owns brand values only; no component CSS.
Dark mode
The theme ships with two built-in dark-mode support patterns:
Manual toggle (production)
Set data-theme="dark" and data-bs-theme="dark" on <html> to flip both WUI and Bootstrap
native components together. The dark token remap maps all --color-* aliases onto dark hexes,
and Bootstrap's utility classes (.text-body, .bg-secondary) and components respond via
the [data-bs-theme="dark"] selector that Bootstrap provides. Pair the attributes—setting
one without the other leaves part of the system on the wrong palette.
Use AppThemeToggle (React) or <app-theme-toggle> (Razor) rather than wiring the attributes
by hand: it offers System, Light and Dark, stores the choice in localStorage, writes both
attributes, and announces wui:theme:change. Nothing stored means System (data-theme="auto"),
so a page follows the device until someone picks a theme. Put the before-paint script in
<head> on every page, before the stylesheets, so the stored theme lands before first paint:
<app-theme-script storage-key="theme" /> on Razor, the inlined output of
themeBootScript("theme") from @webority/ui-react on React.
Automatic detection
Set data-theme="auto" on <html> to detect the system preference via @media (prefers-color-scheme: dark)
and apply the dark remap automatically. The toggle's System choice is this mode; it also writes the
resolved theme to data-bs-theme and keeps it in step when the device changes.
Implementation
Both patterns use the same 60-line dark-token SCSS mixin (defined in _wui-aliases.scss),
so the compiled dark CSS is byte-identical whether toggled manually or via system preference.
Token remap includes:
- Surfaces & text —
--color-bg,--color-border,--color-text* - Bootstrap RGB triplets —
--bs-body-bg-rgb,--bs-body-color-rgb, etc., so.bg-body/.text-bodyutilities flip - Brand & status colors — primary, success, danger, warning, info (mapped to readable values on dark surfaces)
- Scrim — overlay dimming adjusted for dark backgrounds
- Component shadows — slider thumbs, badge accents, etc. re-mapped for visibility
Marketing sites ship light-only (the scss/_marketing.scss partial is opt-in on portals
and omitted on marketing); dark mode is a portal feature, not a marketing requirement.
Public CSS class vocabulary
Theme-owned class names follow one rule going forward. Enforced by
scripts/check-scss-class-prefix.mjs (frozen legacy allowlist + fail on new bare names).
| Class kind | Allowed for new chrome? | Notes |
|---|---|---|
| Bootstrap public classes (.btn, .form-control, .card, …) | Yes | Restyle Layer A only — do not fork a second kit |
| .wui-* | Yes — default | All new library chrome |
| .app-* | Legacy only | Shell / host wrappers already shipped; prefer .wui-* for anything new |
| Bare generics (.input, .search-select, .datepicker, .tbl-*, …) | Legacy only | Frozen allowlist; no new top-level bare selectors |
| Marketing bare (.section-y, .btn-pill, …) | Opt-in _marketing.scss only | Documented marketing vocabulary; portals omit the import |
Renames of legacy bare names are BREAKING: remove the old class, list the new name in
CHANGELOG.md, no dual-class shim. Consumers update markup on the bump.
Library vs product ownership
Three layers. Do not collapse product art direction into the shared package.
| Layer | Owner | What |
|---|---|---|
| A — Bootstrap | Bootstrap | Grid, utilities, reboot, structure of .btn / .form-control / .modal / offcanvas |
| B — Shared UI system | this package + App* | Brandable tokens, one field/selection skin, portal shell/table/modal, hard interactive, React↔Razor parity |
| C — Product / marketing | Each product | Brand hexes, page layouts, heroes/motion, domain components, router/auth/content |
Products own (do not re-implement in the library)
- Brand values —
$primary/--wui-primary*(and related) via pre-bootstrap variables or runtime CSS; usewui-brand-primarywhen setting primary once - Marketing art direction — heroes, section rhythm, marquee, scroll reveals, page entry fades, marketing-only button shapes (pill / ghost-dark), wide marketing canvas
- Screens and domain UI — feature layouts, status→label maps, RoleBadge-style domain components
- App infrastructure: routing, auth, i18n copy, rich text, API (charts are the library's
AppChart)
Products must not own (deviation)
- Forked
AppButton/AppSelect/ theme hex tables - A second select/date/phone kit (Radix, react-day-picker, etc.)
- A second styling system (Tailwind, MUI, hand-rolled
.btnkit) - Per-product copies of shell/table/modal chrome
Ship rule for new PRs into this package
| Proposed change | Put it here if… | Put it in the product if… |
|---|---|---|
| Bootstrap $ or class restyle | Fixes shared a11y, alignment, or rebrand | Looks cool on one landing page |
| New CSS in theme | Both React and Razor portals need the same look | Marketing-only motion or section art |
| New App* / <app-*> | ≥2 products need it, or Bootstrap fails, or parity requires it | One screen / one domain |
| New token | Shared semantic meaning (status, surface, control height) | One brand's one-off hex |
What stays in the library (do not move to products)
Anti-over-correction checklist. When thinning the package (e.g. marketing extract), keep:
Tokens & system (scss/_variables, _tokens, _wui-aliases)
- Theme colors +
wui-brand-primary; body/text/surface/border ramps (incl. AA tertiary) - Radius ladder that differs from Bootstrap; spacer map extensions (6+)
- Status ramps: fills and
-text/ danger-strong for AA - Control heights (
--wui-control-height*); flat in-page elevation; overlay shadows only - Z-index scale (
--wui-z-*); dark-mode--bs-*/ RGB bridges - Focus: no input glow (
$input-focus-box-shadow: none); brand border as focus cue in post-CSS
One skin on Bootstrap classes (not a second kit)
- Fields:
.form-control/.form-selectchrome;.input/.select/.textareaare aliases for size/shell hooks - Selection:
.form-check-inputglyph;.wui-check/.wui-radioare aliases (table select compact under.wui-select-cellonly) - Buttons: tokenized primary / danger / outline / ghost / disabled; shared control height on default size
- Dropdown, card surface/radius, table cell padding via SCSS vars where specificity requires it
Hard interactive & portal patterns (packages outside this folder, same monorepo)
@webority/ui-elements+ App* wrappers: select, multiselect, autocomplete, phone, date, daterange, OTP, command palette- Portal:
AppShell,AppDataTable/ ledger,AppModal/AppConfirmDialog/AppCommandPalette, toast contract,AppSidebarMenu,AppThemeToggle - React↔Razor parity gates and unit tests for catalogue components
Marketing (opt-in — not default)
Editorial helpers live in scss/_marketing.scss, imported as
@webority/theme/scss/marketing after the core chain. Includes section-y,
eyebrow, h-display, hero/CTA gradients, btn-pill / btn-ghost-dark, link-wipe,
container-cloves, hero-wash, page/stagger/reveal motion, marquee, section
content-visibility. Portals omit this import. Marketing sites that used
these classes must add the import.
Catalogue tiers
Keep shipping all of these; classify for when to use, not for removal:
| Tier | Components | Library? | Notes |
|---|---|---|---|
| Portal core | Shell, DataTable, fields/selects, Modal, Confirm, Tabs, Card, Alert, Badge, StatusBadge, Stepper, File*, Date*, Phone, OTP, Toast, Empty/Skeleton/Spinner | Yes — never product-fork | Daily product UI |
| Portal display | AppKpiTile, AppInfoList, AppTimeline, AppSplash, AppBanner, AppBreadcrumbs, AppProgress, AppMeter, AppChart | Yes | Shared across portals |
| Commercial / plan UI | AppPricingCard, AppCompareTable, AppResourceCard | Yes — keep | Pricing pages and in-app plan/billing. Editorial shape, not a second design system. Moving them to products would break React↔Razor parity. |
| CSS marketing only | section-y, btn-pill, reveal, marquee, … | Opt-in partial | Not App*; see marketing import above |
Do not remove PricingCard / CompareTable / ResourceCard without a multi-product usage audit outside this monorepo. Inventory conclusion: keep in library, document tier.
Squircle policy
corner-shape: squircle is applied on many controls (buttons, fields, chips, menus, shell) as progressive enhancement:
- Supporting browsers get slightly smoother corners; others keep plain
border-radius. - Not a cross-browser design contract; not a WCAG requirement.
- Missing squircle is not a bug.
Policy: keep on shared control surfaces for consistent geometry; do not strip globally. Optional .rounded-squircle remains an opt-in utility. Products that want pure arcs only override locally — do not fork App*.
