Styling and Theming
Styling in this application comes from four sources stacked on top of each other, and understanding the order is the difference between a one-line fix and an afternoon of specificity wars:
-
Angular Material — the component primitives.
-
Bootstrap 4 — the grid and utility classes.
-
Metronic — the admin template the project was scaffolded from, which owns the shell layout (
kt-prefixed classes). -
Payability —
@payability/pyb-ngx-componentsplus this repo’s ownsrc/assets/sass/.
On top of the compiled bundle sits a runtime theme layer: two or three CSS
files fetched over HTTP and injected as <style> elements after boot. That
layer is what makes dark mode and white-labelling possible without a rebuild.
Global stylesheets
angular.json loads five global entries, in this order:
"styles": [
"src/styles.scss",
"node_modules/bootstrap/dist/css/bootstrap.css",
"node_modules/intl-tel-input/build/css/intlTelInput.css",
"node_modules/perfect-scrollbar/css/perfect-scrollbar.css",
"src/assets/sass/style.angular.scss"
]
stylePreprocessorOptions.includePaths adds src/assets and two directories
inside @payability/pyb-ngx-components, which is why SCSS files can
@import 'sass/global/…' and @import 'themes/…' without relative paths.
|
Bootstrap arrives twice — once as the prebuilt CSS in the |
src/styles.scss
The catch-all. Material’s indigo-pink prebuilt theme, the Material Icons
font, AG Grid styles, the two aside skins (.pyb-aside-dark,
.pyb-aside-light), and a run of global overrides — hiding the Bootstrap
dropdown caret, hiding the reCAPTCHA badge, body { overflow: hidden }, and
generated utility classes:
@for $i from 0 through 8 {
.mat-elevation-#{$i} { @include mat.elevation($i); }
}
@for $i from 0 through 5 {
.border-radius-#{$i * 5} { border-radius: #{$i * 5}px; }
}
body { overflow: hidden } is load-bearing: the shell scrolls an inner
container, not the document. Removing it produces a double scrollbar.
src/assets/sass/style.angular.scss
The layout cake, with an explicit warning in the file:
////////////////////////////////////////////////////////////////
///////////// DO NOT MODIFY BELOW INCLUDE ORDER ////////////////
////////////////////////////////////////////////////////////////
The order is: shared config from pyb-ngx-components and local _config.scss,
then Bootstrap variables and framework, then Material includes, then component
includes, then per-region layout config and layout (header, brand, aside,
content, footer), and finally custom/index, _fonts.scss and the two
runtime themes' SCSS sources.
Because the layout config partials are imported after Bootstrap, layout variables can reference Bootstrap’s. Reordering silently changes computed values rather than failing the build, which is why the banner is there.
Directory map
|
Pulls in shared functions and mixins, then |
|
Metronic shell regions: |
|
Third-party adapters. |
|
|
|
Project-specific: |
|
The two page-specific sheets that could not be component-scoped: marketplace setup and two-factor setup. |
custom/_variables.scss and custom/_root.scss are each a single @import
that re-exports the shared definitions from @payability/pyb-ngx-components,
plus locally added variables such as $lower-zoom-breakpoint: 300px. Shared
design tokens belong in the component library, not here.
Runtime themes
Three CSS files ship as assets rather than being bundled, copied from
@payability/pyb-ngx-components/src/themes/ to dist/themes/:
|
Standard Payability branding. |
|
Newegg Capital white label. |
|
Dark mode. Layered on top of whichever default theme is active, never on its own. |
PybThemeService (from @payability/pyb-ngx-components) fetches these with
HttpClient and injects the text into a <style> element it keeps a handle on:
private loadFile(applyDarkTheme?: boolean): void {
const url = `themes/${this.defaultThemeApplied}.css`;
this.http.get(url, { responseType: 'text' }).subscribe(result => {
this.attachStyles(result, 'defaultThemeElement');
if (applyDarkTheme) {
this.darkThemeApplied = true;
this.loadDarkModeFile();
}
});
}
Switching a theme means removing the old <style> element and fetching the new
file. loadDarkModeFile() additionally adds pyb-client-provider—dark-mode to
<body>, which is the hook every dark-mode SCSS rule keys off.
|
Theme URLs are relative ( |
Client providers (white labelling)
ClientProviderService (core/pyb/services/service-provider/) decides which
brand the application is wearing. Two providers exist:
export enum PybClientProvider {
Default = 'supplier',
NewEgg = 'new-egg',
}
export enum PybClientHost {
Default = 'supplier4.payability.com',
NewEgg = 'neweggcapital.payability.com',
}
Selection uses two independent signals, either of which is sufficient:
-
Host —
window.location.hostcontainsnewegg. -
User —
user.onboardingTemplateCode === 'neweggcapital'.
The host check applies before login, so the unauthenticated screens are already
branded. The user check runs on UserLoaded and on any change of
onboardingTemplateCode, so a Newegg supplier who somehow arrives on the
default host is switched over after authentication.
Each provider is a strategy extending ClientProviderAbstractStrategy, and
carries just two properties:
export class ClientProviderNewEggStrategy extends ClientProviderAbstractStrategy {
protected readonly styleClass = 'pyb-client-provider--new-egg';
protected readonly theme = PybTheme.NewEgg;
}
applyStyles() swaps the body class and the theme, preserving dark mode across
the switch by tearing it down first and re-applying it after:
applyStyles(): PybClientProviderStrategy {
const isDarkMode = localStorage.getItem('dark-mode-enabled');
if (isDarkMode) { this.themeService.resetDarkTheme(); } (1)
this.cleanUp(); (2)
this.rd2.addClass(document.body, this.styleClass);
this.themeService.updateDefaultTheme(this.theme);
this.themeService.useDefaultTheme();
if (isDarkMode) { this.themeService.toggleDarkTheme(); } (3)
return this;
}
| 1 | Remove the dark <style> element so it does not sit under the new base theme. |
| 2 | cleanUp() finds and removes whichever body class contains pyb-client-provider, so providers cannot accumulate. |
| 3 | Re-apply dark mode, which re-fetches dark-theme.css. |
Because steps 2 and 3 are separate HTTP fetches, a provider switch with dark mode on produces a brief flash of the light base theme.
Adding a provider
-
Add the enum members to
client-provider.model.ts(bothPybClientProviderandPybClientHost). -
Add a
PybThemevalue and a theme CSS file in@payability/pyb-ngx-components. -
Add a strategy class declaring
styleClassandtheme. -
Register it in
ClientProviderFactory. -
Add the selection signal — host match, template code, or both — in
ClientProviderService.updateTemplate().
Nothing in this list is permission-gated; branding and access control are
independent. If a provider should also see a different feature set, add that to
applyPermissions(), which the abstract strategy leaves as a no-op.
Dark mode
Dark mode is a user preference, on by default for nobody, and available to
every authenticated user — PermissionsEnum.DARK_MODE is in the default
permission set (Permissions).
The toggle lives in the footer:
toggleTheme(): void {
if (this.theme.isDefaultTheme) {
localStorage.setItem('dark-mode-enabled', 'dark-mode-enabled');
} else {
localStorage.removeItem('dark-mode-enabled');
}
this.theme.toggleDarkTheme();
}
The preference is stored in localStorage under dark-mode-enabled — not in
NgRX, and not on the server. It is per-browser and does not follow a supplier
to another device. ClientProviderService.init() reads it during startup and
passes it to PybThemeService.init() so the dark file is fetched in the same
pass as the base theme, avoiding a flash of light content.
|
The stored value is the string |
Writing dark-mode styles
src/assets/sass/global/themes/_dark-mode.scss nests everything under the body
class and uses CSS custom properties for colours:
body.pyb-client-provider {
&--dark-mode {
.kt-header-mobile,
.kt-subheader,
.reconciliation-version-slide {
background-color: var(--black-5) !important;
}
}
}
Two consequences for new work:
-
Every rule needs
!important, because it is competing with equally specific rules from the base theme that loaded first. -
Any component that hard-codes a colour is invisible to dark mode.
ktStickysetsbackground-color: #fffinline (Utility Reference), andstyles.scsshard-codes white on.mat-snack-bar-container.toast-big. Prefervar(--…)tokens.
When adding a surface, check it in both modes before shipping. There is no automated coverage for theming.
Component styles
231 component stylesheets exist, and Angular’s default emulated encapsulation applies. Two escape hatches are in use and both should be a last resort:
|
9 components. Their styles are global — a selector collision is a bug anywhere in the app. |
|
94 occurrences, almost all reaching into Material internals ( |
There are 86 !important declarations across component styles. Most exist to
beat Material or Metronic. Before adding another, check whether a
::ng-deep-scoped rule at higher specificity does the job.
Responsive behaviour
Breakpoints come from BreakpointService in @payability/pyb-ngx-components,
which wraps the Angular CDK BreakpointObserver:
breakpoint.mobile // or isMobile()
breakpoint.tablet // or isTablet()
breakpoint.mobileOrTablet // or isMobileOrTablet()
breakpoint.changed$ // BehaviorSubject<void>, emits on every change
Components inject it as a public readonly so templates can read the getters
directly. changed$ is the signal to re-measure or re-render; with OnPush
you need to subscribe and mark for check rather than relying on the getters
alone.
Several features ship a distinct mobile component rather than a responsive one —
fee-report-mobile, daily-summary-mobile — because AG Grid does not degrade
usefully at phone widths. See Reports.
The shell’s own responsive behaviour (aside collapse, mobile header) is
Metronic’s, driven by body classes from HtmlClassService. See
Application Shell.
Icons
Three sources, in rough order of preference:
- Material Icons font
-
material-design-iconsis imported instyles.scss; use<mat-icon>home</mat-icon>. - The Metronic SVG icon set
-
MaterialModuleregisters the whole set once at startup:iconRegistry.addSvgIconSet(domSanitizer.bypassSecurityTrustResourceUrl('assets/media/icons'), iconOptions);Icons then resolve by name through
<mat-icon svgIcon="…">. - Individually registered SVGs
-
A handful of components call
addSvgIconin their own constructors, for example:iconRegistry.addSvgIcon('locked', sanitizer.bypassSecurityTrustResourceUrl('assets/media/icons/svg/General/Locked.svg'));
|
Per-component registration writes into the global icon registry, so the name
is claimed application-wide by whichever component was constructed last.
Register new icons in |
Fonts
src/assets/sass/_fonts.scss is a single re-export of the @font-face
declarations in @payability/pyb-ngx-components, so font files are the
component library’s concern, not this repo’s. Production builds set
optimization.fonts: true, which inlines and subsets them.
Where to make a change
One component’s appearance |
Its own |
A shell region — header, aside, footer, subheader |
|
A colour used in more than one place |
A CSS custom property in |
Dark-mode-only correction |
|
A new brand |
|
An AG Grid visual |
|
Nothing in the styling layer is covered by tests, and Prettier does not reorder SCSS imports. Verify visually in light mode, dark mode, and — if the change touches shared layout — on the Newegg host.