Status: DRAFT β This document is awaiting review by an XH developer. See docs-roadmap.md for the review workflow.
The /styles/ package defines Hoist's visual foundation: a comprehensive system of CSS custom
properties (CSS vars) that control colors, spacing, typography, borders, and component-specific
appearance across the entire framework. Combined with BEM-style class naming conventions and
co-located SCSS files per component, this system enables consistent theming β including built-in
dark mode β while giving applications a clear, well-scoped mechanism for customization.
Hoist uses SCSS (Sass) as its stylesheet preprocessor, but deliberately limits its use of
SCSS-specific features. The heavy lifting of theming and customization is done through CSS custom
properties, not SCSS variables. SCSS serves primarily as a convenience for nesting, &-based BEM
selectors, and a small number of color functions used to generate default values at compile time.
| File | Purpose |
|---|---|
vars.scss |
Defines all --xh-* CSS custom properties on body, with light/dark/mobile variants |
XH.scss |
Global stylesheet β imports vars, sets up base body.xh-app styles, defines utility classes |
helpers.scss |
Small SCSS utility functions (inline SVG encoding) used internally |
Every --xh-* variable follows a two-tier pattern that enables app-level overrides:
// In vars.scss β framework default
--xh-grid-bg: var(--grid-bg, var(--xh-bg));This reads as: "Use --grid-bg if the app has defined it, otherwise fall back to --xh-bg." The
outer --xh-grid-bg is the variable that component SCSS files actually reference. The inner
--grid-bg (without the xh- prefix) is the app-level override hook.
This means applications can customize any Hoist style by setting the unprefixed variable:
// In an app's SCSS β override grid background
body.xh-app {
--grid-bg: #fafafa;
}Applications should not redefine --xh-* variables directly β those are framework-managed.
Use the unprefixed override hooks instead.
The vars.scss file organizes ~300 CSS custom properties into these categories:
| Category | Prefix Pattern | Examples |
|---|---|---|
| Core Colors | --xh-{color} |
--xh-blue, --xh-gray-dark, --xh-red-muted |
| Intent Colors | --xh-intent-{intent}-* |
--xh-intent-primary, --xh-intent-danger-lighter |
| Positive/Negative | --xh-{pos|neg|neutral}-val-color |
--xh-pos-val-color, --xh-neg-val-color |
| Background | --xh-bg* |
--xh-bg, --xh-bg-alt, --xh-bg-highlight |
| Text | --xh-text-color* |
--xh-text-color, --xh-text-color-muted, --xh-text-color-accent |
| Typography | --xh-font-* |
--xh-font-family, --xh-font-size-px, --xh-font-size-large-em |
| Spacing | --xh-pad* |
--xh-pad-px, --xh-pad-half-px, --xh-pad-double-px |
| Borders | --xh-border-* |
--xh-border-color, --xh-border-solid, --xh-border-radius-px |
| Component-Specific | --xh-{component}-* |
--xh-grid-bg, --xh-panel-title-bg, --xh-tbar-min-size-px |
Components with dedicated variable sets include: AppBar, Badge, Button, Card, Chart, Form Field, Grid (including Large, Compact, Tiny, and ZoneGrid variants), Input, Loading Indicator, Mask, Menu, Panel, Popup, Resizable Splitter, Scrollbar, Tab, Title, Toolbar, and Viewport.
Many size-related variables store unitless numbers and provide a computed -px companion:
--xh-pad: var(--pad, 10);
--xh-pad-px: calc(var(--xh-pad) * 1px);This enables calc() operations on the base value while providing a ready-to-use pixel variant
for direct property assignment. Apps overriding these values should set the unprefixed variable
to a unitless number:
// β
Do: Set a unitless number
body.xh-app { --pad: 8; }
// β Don't: Include units in the override
body.xh-app { --pad: 8px; }Intent colors (neutral, primary, success, warning, danger) use an HSL decomposition pattern that supports multiple lightness variants from a single hue/saturation definition:
// Base HSL components
--xh-intent-primary-h: var(--intent-primary-h, 206);
--xh-intent-primary-s: var(--intent-primary-s, 100%);
--xh-intent-primary-l3: var(--intent-primary-l3, 30%);
// Composed into named variants
--xh-intent-primary: hsl(var(--xh-intent-primary-h), var(--xh-intent-primary-s), var(--xh-intent-primary-l3));
--xh-intent-primary-darker: hsl(..., var(--xh-intent-primary-l2));
--xh-intent-primary-lighter: hsl(..., var(--xh-intent-primary-l4));
--xh-intent-primary-trans1: hsla(..., var(--xh-intent-a1)); // Semi-transparentEach intent provides seven usable variants: -darkest, -darker, (base), -lighter, -lightest,
-trans1, and -trans2. This gives component styles a rich palette without requiring apps to
define every shade β overriding just the hue (--intent-primary-h) will shift all derived variants.
The framework defaults for core colors are derived from Google's Material Design palette via the
sass-material-colors library and three SCSS helper functions:
@function mc($color-name, $color-variant) { ... } // e.g. mc('blue', '700')
@function mc-muted($color-name, $color-variant, ...) { ... } // Desaturated + lightened
@function mc-trans($color-name, $color-variant, ...) { ... } // Semi-transparentThese functions run at compile time to generate static CSS fallback values. They are used only
within vars.scss to seed defaults β component SCSS files and application stylesheets reference
CSS custom properties, never these functions directly.
Hoist's dark theme is implemented via CSS class toggling on the <body> element:
ThemeModel(in/appcontainer/) manages the active theme, persisted via thexhThemepreference (values:'light','dark','system')- When dark mode activates,
ThemeModeladds the classxh-dark(andbp6-darkfor Blueprint compatibility) todocument.body - In
vars.scss,&.xh-dark { ... }blocks override the relevant--xh-*variables with dark-appropriate values - All component styles automatically adapt because they reference CSS vars, not static colors
// vars.scss
--xh-bg: var(--bg, white);
&.xh-dark {
--xh-bg: var(--bg, var(--xh-black));
}The system option listens for prefers-color-scheme media query changes and automatically
tracks the OS-level preference.
Most dark theme adaptation happens through the variable overrides in vars.scss. In rare cases
where a component needs dark-specific styling that can't be expressed as a variable swap, component
SCSS files use the .xh-dark & selector pattern:
.xh-grid-menu-option {
&--intent-success {
color: var(--xh-intent-success-darker);
}
.xh-dark & {
&--intent-success {
color: var(--xh-intent-success-lighter);
}
}
}Several variables also have &.xh-mobile overrides for platform-specific defaults (e.g. larger
font sizes, taller toolbars, different AppBar colors). These combine with dark theme:
&.xh-mobile {
--xh-font-size: var(--font-size, 16);
&.xh-dark {
--xh-appbar-bg: var(--appbar-bg, #{mc('blue-grey', '700')});
}
}All Hoist CSS classes use the xh- prefix and follow BEM (Block Element Modifier) naming:
- Block:
.xh-panel,.xh-grid,.xh-card - Element:
.xh-panel__inner,.xh-card__header,.xh-popup__title - Modifier:
.xh-grid--hierarchical,.xh-card--collapsed,.xh-card--intent-primary
Hoist leverages SCSS's parent selector (&) to write BEM selectors without repeating the block
name. This is one of the most important and pervasive uses of SCSS in the codebase:
.xh-card {
background-color: var(--xh-card-bg);
// Modifiers on the block
&--collapsed {
border-bottom: none;
}
&--intent-primary {
border-color: var(--xh-card-primary-color);
}
// Elements
&__header {
color: var(--xh-card-header-text-color);
font-size: var(--xh-card-header-font-size-px);
// Modifiers on the element
&--intent-primary {
color: var(--xh-card-primary-color);
}
}
&__content {
flex-direction: column;
}
}This compiles to flat, specificity-friendly selectors: .xh-card--collapsed,
.xh-card__header--intent-primary, etc.
Each component has its own SCSS file, co-located with its TypeScript source:
cmp/card/
Card.ts // Component implementation
Card.scss // Component styles
CardModel.ts // Component model
The component imports its stylesheet as a side-effect import:
import './Card.scss';Hoist components accept a className prop and merge it with framework-defined classes using the
classnames library. The base class is declared in hoistCmp.withFactory():
export const [Card, card] = hoistCmp.withFactory<CardProps>({
displayName: 'Card',
className: 'xh-card', // Base BEM block class
render({className, ...props}) {
// Framework adds modifier classes based on props/state
return div({
className: classNames(className, {
'xh-card--collapsed': collapsed,
'xh-card--intent-primary': intent === 'primary'
}),
items: [...]
});
}
});This means application code can always pass additional classes that will be merged alongside the framework's own classes:
card({className: 'my-app-card', title: 'Summary', ...})XH.scss defines a set of utility classes scoped under body.xh-app for common styling needs:
| Category | Classes | Example |
|---|---|---|
| Colors | .xh-blue, .xh-red, .xh-text-color-muted, etc. |
Quick text coloring |
| Intents | .xh-intent-primary, .xh-bg-intent-danger |
Intent-based text/background |
| Pos/Neg | .xh-pos-val, .xh-neg-val, .xh-neutral-val |
Financial value coloring |
| Alignment | .xh-align-center, .xh-align-left, .xh-align-right |
Text + flex alignment |
| Backgrounds | .xh-bg, .xh-bg-alt, .xh-bg-highlight |
Background colors |
| Borders | .xh-border, .xh-border-top, .xh-border-dotted |
Quick borders |
| Typography | .xh-bold, .xh-font-family-mono, .xh-font-size-large |
Text styling |
| Spacing | .xh-pad, .xh-pad-half, .xh-margin-lr, .xh-pad-none |
Padding/margin |
These utility classes all reference CSS vars, so they automatically adapt to the active theme.
Hoist uses a deliberately limited subset of SCSS features:
| Feature | Usage | Example |
|---|---|---|
| Nesting | Extensively β for BEM selectors and scoping | &__header { ... } |
& parent selector |
Core to BEM pattern | &--collapsed, &__content |
@use / @import |
Module imports | @use 'vars';, @use 'sass:color'; |
| Functions | Color manipulation in vars.scss |
mc(), mc-muted(), mc-trans() |
@mixin / @include |
Sparingly, for reusable patterns | Grid border mixins |
@for loops |
Rare, for generating depth-based styles | Tree grid row-level backgrounds |
calc() |
Frequently, with CSS vars | calc(var(--xh-pad) * 1px) |
| Feature | Why Avoided |
|---|---|
SCSS $variables |
CSS custom properties provide runtime theming; SCSS vars are compile-time only |
@extend |
Can cause specificity issues and unexpected selector output |
%placeholders |
Not used β @extend is avoided |
@each loops |
Complexity not warranted |
| Deep nesting | Kept shallow to maintain readable, low-specificity output |
The guiding principle: use SCSS for developer ergonomics (nesting, & selectors), but rely on
CSS custom properties for anything that needs to be themeable or overridable at runtime.
Applications customize Hoist's appearance by setting the unprefixed override hooks in their own SCSS. A typical app-level stylesheet:
// App.scss
body.xh-app {
// Override core spacing
--pad: 8;
// Customize form field labels
--form-field-label-color: var(--xh-text-color-muted);
--form-field-label-font-size: var(--xh-font-size-small-px);
--form-field-label-text-transform: uppercase;
// Dark theme specific overrides
&.xh-dark {
--xh-tbar-bg: #1d272c;
}
}Applications define their own classes using an app-specific prefix (e.g. tb- for Toolbox, js-
for Jobsite) and can also target Hoist framework classes for contextual overrides:
// App-prefixed classes follow the same BEM pattern
.js-release-date-badge {
font-weight: 500;
&--clickable:hover {
cursor: pointer;
}
}
// Targeting framework classes for app-specific tweaks
.xh-appbar-icon {
img {
height: 25px;
margin-left: var(--xh-pad-px);
}
}Applications may need to style third-party library elements (e.g. Blueprint, ag-Grid). Reference Hoist CSS vars for consistency:
body.xh-app {
// ag-Grid integration β use Hoist var for consistent background
[class*='ag-theme-'] {
--ag-data-background-color: var(--xh-bg);
}
// Blueprint tooltip tweaks
.bp6-tooltip .bp6-popover-content {
background-color: var(--xh-bg);
color: var(--xh-text-color);
}
}/appcontainer/βThemeModelmanages dark/light theme toggling and persistence/cmp/β Component SCSS files that consume--xh-*vars/desktop/β Desktop-specific component styles/mobile/β Mobile-specific component styles- Coding Conventions β CSS class naming rules (
xh-prefix, BEM,--xh-*variable namespace)
// β Don't: Override the framework-managed variable
body.xh-app {
--xh-grid-bg: #fafafa;
}
// β
Do: Use the unprefixed override hook
body.xh-app {
--grid-bg: #fafafa;
}The --xh-* prefix is reserved for the framework. While directly overriding them will technically
work, it bypasses the two-tier indirection and may be overwritten by future framework updates.
// β Don't: Use SCSS variable β won't respond to theme changes
$my-bg: white;
.my-panel { background: $my-bg; }
// β
Do: Use CSS custom property β adapts with theme
.my-panel { background: var(--xh-bg); }SCSS variables are resolved at compile time and cannot respond to runtime theme toggling.
// β Don't: Include px units β breaks calc() expressions
body.xh-app { --font-size: 14px; }
// β
Do: Use unitless numbers for size overrides
body.xh-app { --font-size: 14; }Hoist's unitless-plus--px pattern uses calc(var(...) * 1px) to add units β if the source
value already includes units, the calculation produces invalid values like 14px * 1px.
// β Don't: Over-nest to create high-specificity chains
body.xh-app .xh-panel .xh-panel__inner .xh-grid { ... }
// β
Do: Target the class directly
.xh-grid { ... }Hoist's BEM naming ensures class uniqueness without requiring deep selector chains. High-specificity selectors make overrides brittle and harder to reason about.