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:

  1. Angular Material — the component primitives.

  2. Bootstrap 4 — the grid and utility classes.

  3. Metronic — the admin template the project was scaffolded from, which owns the shell layout (kt- prefixed classes).

  4. Payability — @payability/pyb-ngx-components plus this repo’s own src/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 styles array and again as SCSS via @import '~bootstrap/scss/bootstrap' inside both styles.scss and style.angular.scss. The duplication is longstanding and inflates the CSS bundle. Removing it requires checking that nothing depends on the later-wins cascade.

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

src/assets/sass/_config.scss

Pulls in shared functions and mixins, then global/init.

src/assets/sass/global/layout/

Metronic shell regions: base/, header/, brand/, aside/, content/, footer/. Each has a _config.scss of variables and a partial of rules.

src/assets/sass/global/integration/

Third-party adapters. frameworks/ covers Bootstrap, Material and AG Grid; plugins/ holds 24 partials for jQuery-era widgets (_jstree, _tinymce, _fullcalendar, …) that the application does not use and which are inherited template weight.

src/assets/sass/global/themes/

_dark-mode.scss and _new-egg.scss — the source for the runtime theme CSS.

src/assets/sass/custom/

Project-specific: _variables.scss, _typography.scss, _root.scss (CSS custom properties), _pyb-components.scss, ag-grid.scss.

src/assets/sass/pages/

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/:

default-theme.css

Standard Payability branding.

new-egg-theme.css

Newegg Capital white label.

dark-theme.css

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 (themes/default-theme.css), which works because the application is served from the domain root. It also means the request goes through Angular’s HttpClient and therefore through the interceptor chain — the theme fetch carries an Authorization header it does not need.

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:

  1. Host — window.location.host contains newegg.

  2. 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

  1. Add the enum members to client-provider.model.ts (both PybClientProvider and PybClientHost).

  2. Add a PybTheme value and a theme CSS file in @payability/pyb-ngx-components.

  3. Add a strategy class declaring styleClass and theme.

  4. Register it in ClientProviderFactory.

  5. 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 'dark-mode-enabled', and code reads it with a truthiness check. Writing any other non-empty string to that key also enables dark mode. FooterComponent seeds its FormControl from !!localStorage.getItem('dark-mode-enabled') in ngOnInit, so the switch reflects reality only at construction — a theme change made anywhere else does not move it.

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. ktSticky sets background-color: #fff inline (Utility Reference), and styles.scss hard-codes white on .mat-snack-bar-container.toast-big. Prefer var(--…) 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:

ViewEncapsulation.None

9 components. Their styles are global — a selector collision is a bug anywhere in the app.

::ng-deep

94 occurrences, almost all reaching into Material internals (.mat-form-field-…, .mat-dialog-container). Deprecated by Angular with no replacement; scope it under a host class so it cannot leak.

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-icons is imported in styles.scss; use <mat-icon>home</mat-icon>.

The Metronic SVG icon set

MaterialModule registers 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 addSvgIcon in 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. add-selling-info-modal registers locked pointing at Locked.svg; transfer-funds-modal registers the same name locked pointing at Star.svg. Opening one modal changes the icon the other renders.

Register new icons in MaterialModule with a unique, prefixed name instead.

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 .scss. Default encapsulation.

A shell region — header, aside, footer, subheader

src/assets/sass/global/layout/{region}/. Check both themes.

A colour used in more than one place

A CSS custom property in @payability/pyb-ngx-components, surfaced through custom/_root.scss.

Dark-mode-only correction

global/themes/_dark-mode.scss, nested under body.pyb-client-provider—​dark-mode.

A new brand

PybTheme and a theme CSS file in the component library, plus a strategy here.

An AG Grid visual

custom/ag-grid.scss and global/integration/frameworks/ag-grid/.

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.