Styling and Theming in Neo.mjs
This guide provides a comprehensive overview of how to style your Neo.mjs applications, from basic component styling to creating entirely new themes.
The Core Concepts
Styling in Neo.mjs is a layered system designed for both performance and flexibility. It combines component-level styles, a powerful SCSS-based theme engine, and a lazy-loading mechanism to ensure that only the necessary CSS is loaded at any given time.
Here are the key pillars of the styling system:
- Component-Based Styles: Applying styles directly to component instances.
- VDOM-Based Styles: How component styles are translated into the virtual DOM.
- SCSS & Theming: The structure of the SCSS source files and how themes are built.
- Theme Inheritance: How themes can extend and override base styles.
- Build Process: The scripts used to compile SCSS into CSS.
- Lazy Loading: How the engine efficiently loads theme styles on demand.
Let's dive into each of these areas.
1. Component-Based Styles
The primary way to style individual components is through configuration properties on the component itself. The Neo.component.Base class provides several key configs for this purpose.
style
For applying inline CSS styles, use the style config. It accepts a standard JavaScript object. While the recommended convention is to use camelCased keys for CSS properties, you can also use quoted kebab-case keys.
// Recommended: camelCase
{
ntype: 'button',
text: 'Click Me',
style: {
backgroundColor: 'blue',
color: 'white',
borderRadius: '5px'
}
}
// Also valid: quoted kebab-case
{
ntype: 'button',
text: 'Click Me',
style: {
'background-color': 'blue',
'color': 'white',
'border-radius': '5px'
}
}
This will be rendered as style="background-color: blue; color: white; border-radius: 5px;" on the component's main HTML element.
cls
To apply CSS classes, use the cls config, which accepts an array of strings.
{
ntype: 'panel',
cls: ['my-custom-panel', 'another-class']
}
Neo.mjs will automatically add its own classes for theming and functionality, so your custom classes will be merged with the engine's classes.
ui
The ui config is a special styling hook that provides a simple way to apply a predefined set of styles to a component. It works by adding a CSS class in the format neo-<ntype>-<ui>.
For example, if you have a button:
{
ntype: 'button',
text: 'Important Button',
ui: 'primary'
}
This will add the class neo-button-primary to the button's element, allowing you to target it with specific styles in your theme.
Implementing a Custom ui Style
To create your own ui variant, you simply add the corresponding CSS rule to your theme's SCSS file. For example, to create a "success" button style, you would first set the config:
{
ntype: 'button',
text: 'Save Changes',
ui: 'success'
}
Then, in your theme's SCSS file (e.g., resources/scss/theme-my-theme/button/Base.scss), you would add the style definition:
.neo-button-success {
background-color: #28a745; // Green for success
color: white;
// any other styles...
}
Advanced Styling: Wrappers and Root Nodes
For more advanced control, Neo.component.Base provides a set of five style and class-related configs. Understanding them requires understanding the difference between a component's outermost node and its logical root node (getVdomRoot()).
- For many simple components, these two nodes are the same.
- For complex components, the logical root might be a child of the outermost node. For example, a grid cell component's logical root might be a
<div>, but its outermost node is the<td>that wraps it.
This distinction explains the purpose of the different configs:
style: An object of inline styles applied to the component's logical root node.cls: An array of CSS classes applied to the component's logical root node.wrapperStyle: An object of inline styles applied to the component's outermost node. This is only needed when the outermost node is different from the logical root.wrapperCls: An array of CSS classes applied to the component's outermost node, for the same reason aswrapperStyle.baseCls: An array of fundamental CSS classes applied by the component class itself for its core functionality. This is for internal engine use and is automatically merged into the finalclsarray.
Best Practice: cls vs. style
Whenever possible, it is considered best practice to use cls instead of style. Defining styles in CSS classes keeps your component definitions cleaner and makes your styles more reusable and maintainable.
The style config should be reserved for situations where style properties are being calculated dynamically at runtime and are specific to that single component instance. A perfect example is a resizable Dialog component. As a user drags the corner of the dialog, the engine will dynamically update its width and height via the style config. These are transient, calculated values that don't belong in a reusable CSS class.
2. VDOM-Based Styles
All component configurations, including style and cls, are ultimately applied to the component's Virtual DOM (VDOM) tree. The engine then efficiently updates the real DOM based on changes to the VDOM.
When you change a style-related config at runtime, the component's afterSet hook for that config (e.g., afterSetStyle()) is triggered. This hook updates the VDOM, and the engine's rendering pipeline applies the changes to the live DOM. This reactive system ensures that UI updates are fast and automatic.
Where to Apply Styles: A Critical Distinction
To avoid conflicts and ensure the reactive system works correctly, it is critical to follow this rule:
For the component's root VDOM node(s): Always use the component-level configs (
cls,style,wrapperCls,wrapperStyle). Do not addclsorstyleattributes directly to the root node within thevdomobject itself. This allows the engine to manage these styles reactively. If you set them directly on the VDOM root, your styles could be overwritten by a config change, or they could conflict with it.For all other descendant VDOM nodes: Use the standard inline
cls(as an array) andstyle(as an object) attributes directly inside the VDOM structure. This is the correct and intended way to style the inner parts of your component.
// GOOD EXAMPLE
{
ntype: 'container',
// Use component configs for the root node
cls: ['my-container'],
style: { border: '1px solid blue' },
// Define VDOM with inline styles for descendants
vdom: {
// No cls or style here!
cn: [{
tag: 'h1',
cls: ['my-title'], // Correct for a descendant
style: { color: 'blue' }, // Correct for a descendant
text: 'My Component'
}, {
// ... other descendant nodes
}]
}
}
3. SCSS & Theming
Neo.mjs's theming system is built with SCSS. The source files are located in the resources/scss directory.
The structure is typically:
resources/scss/src/: Contains the base structural styles for all components. These are the un-themed, core styles.resources/scss/theme-light/: Contains the styles for the light theme.resources/scss/theme-dark/: Contains the styles for the dark theme.
Within each of these folders, the SCSS files are organized to mirror the component's namespace in the src directory. For example, the styles for Neo.button.Base would be located at:
resources/scss/src/button/Base.scssresources/scss/theme-light/button/Base.scssresources/scss/theme-dark/button/Base.scss
4. SCSS File & Namespace Mapping
For the automatic lazy-loading of theme files to work, it is critical that the path of an SCSS file mirrors the namespace of the JavaScript class it styles. The build process uses this convention to generate the theme-map.json.
Framework Components
For standard engine components, the mapping is direct. The path within resources/scss/src (or a theme folder) matches the class path after Neo..
- JS Class:
src/button/Base.mjs(which definesNeo.button.Base) - Maps to SCSS:
resources/scss/src/button/Base.scss
Application Components (The view rule)
Applications follow a similar rule, but with one important exception: the view folder in the JavaScript path is omitted from the SCSS path. It is a mandatory convention that only components inside an application's view folder should have associated SCSS files.
- JS Class:
apps/portal/view/Viewport.mjs(definesPortal.view.Viewport) - Maps to SCSS:
resources/scss/src/apps/portal/Viewport.scss
Notice how view/ is not present in the SCSS path. The engine's build tools and runtime loader are specifically coded to handle this convention. Adhering to it is essential for your application's styles to be loaded correctly.
4. SCSS File & Namespace Mapping
For the automatic lazy-loading of theme files to work, it is critical that the path of an SCSS file mirrors the namespace of the JavaScript class it styles. The build process uses this convention to generate the theme-map.json.
Framework Components
For standard engine components, the mapping is direct. The path within resources/scss/src (or a theme folder) matches the class path after Neo..
- JS Class:
src/button/Base.mjs(which definesNeo.button.Base) - Maps to SCSS:
resources/scss/src/button/Base.scss
Application Components (The view rule)
Applications follow a similar rule, but with one important exception: the view folder in the JavaScript path is omitted from the SCSS path. It is a mandatory convention that only components inside an application's view folder should have associated SCSS files.
- JS Class:
apps/portal/view/Viewport.mjs(definesPortal.view.Viewport) - Maps to SCSS:
resources/scss/src/apps/portal/Viewport.scss
Notice how view/ is not present in the SCSS path. The engine's build tools and runtime loader are specifically coded to handle this convention. Adhering to it is essential for your application's styles to be loaded correctly.
5. Theme Inheritance
The theming engine uses a powerful and automatic inheritance model. You do not need to manually @import base styles into your theme's SCSS files. The engine handles this for you at runtime.
Here's how it works: When a component is created, the insertThemeFiles() method (in src/worker/App.mjs) inspects the component's entire JavaScript prototype chain. It walks up the chain from the component's class (e.g., MyApp.view.CustomButton) through its parents (like Neo.button.Base, Neo.component.Base, etc.) and loads the corresponding CSS file for each class that has one.
This means that the styles from src are always loaded as the base, and your theme's styles are automatically applied on top of them as overrides.
This approach has two major benefits:
- Simplicity: Your theme files only need to contain the specific styles you want to change. You don't need to worry about managing complex SCSS imports.
- Accuracy: The CSS inheritance perfectly mirrors the JavaScript class inheritance.
A theme file can therefore be very clean and focused:
// resources/scss/theme-dark/button/Base.scss
// No @import needed!
.neo-button {
// Dark theme overrides
background-color: var(--dark-button-background-color);
color: var(--dark-button-text-color);
}
You can also create your own themes that inherit from the existing Neo.mjs themes. The same principle applies: the engine will load the base theme's CSS first, followed by your new theme's CSS.
6. Architecting Nestable Themes
A key feature of the Neo.mjs theming architecture is the ability to nest components with different themes inside each other. For example, you could have a dark-themed grid inside a light-themed panel. To make this work reliably, themes must follow a strict separation of concerns.
The Golden Rule:
resources/scss/src/defines structure: The SCSS files in thesrcdirectory should define all the CSS selectors and structural properties (likedisplay,position,overflow, etc.). They use CSS variables (var()) for all cosmetic properties (likecolor,background-color,border, etc.).resources/scss/theme-*/defines the skin: Theme files should, ideally, only contain definitions for CSS variables. They should not introduce new selectors or override structural properties.
A Practical Example: list/Base.scss
This principle is clearly demonstrated in the styling for Neo.list.Base.
Structure (src/list/Base.scss):
.neo-list-wrapper {
background-color: var(--list-container-background-color);
border : var(--list-container-border);
overflow : hidden;
position : relative;
}
.neo-list-item {
background-color: var(--list-item-background-color);
padding : var(--list-item-padding);
/* ... more structural styles */
}
This file sets up the rules. It says that a .neo-list-wrapper can have a border, and its value is determined by the --list-container-border variable.
Skin (theme-dark/list/Base.scss):
:root .neo-theme-dark {
--list-container-border: 1px solid #282829;
/* ... other dark theme variables */
}
The dark theme provides a value for the border variable.
Skin & Nullification (theme-light/list/Base.scss):
:root .neo-theme-light {
--list-container-border: 0;
/* ... other light theme variables */
}
The light theme explicitly sets the border variable to 0. This is called nullification. It's critical for nesting. If a light-themed list is placed inside a dark-themed component, this rule ensures the list does not incorrectly inherit the dark theme's 1px border. It actively resets the property defined in the src structure.
Bad Practice & The Right Way Forward
While you technically can add new selectors or structural overrides inside a theme file, it is considered bad practice if you want your theme to be nestable and composable with other themes. Doing so can lead to unpredictable side effects when themes are mixed and matched.
What if a needed selector doesn't exist in src?
There might be cases where your custom design requires styling a part of a component that doesn't have a dedicated CSS class or selector in the base src files. Here is the recommended workflow:
- Temporarily add the selector to your theme: To keep your project moving, it is acceptable to add the new structural selector directly into your theme's SCSS file as a temporary measure.
- Open a feature request: Immediately after, you should open a feature request ticket in the Neo.mjs GitHub repository. The ticket should describe the component you are styling and the new selector(s) you need.
This process allows you to continue your work without being blocked, while also contributing back to the engine. Once the new selectors are added to the src files in a future Neo.mjs update, you can refactor your theme to remove the temporary structural code and use the new, official CSS variables instead. This keeps themes clean and aligned with the engine's architecture for the long term.
If you are creating a one-off, custom theme that will never be nested, this rule is less critical. However, for creating robust, reusable themes, sticking to the "structure vs. skin" separation is essential.
7. Creating a New Theme
The most efficient and recommended way to create a new theme is to start with an existing one. This ensures you have all the necessary folder structures and CSS variable definitions in place.
Choose a Base Theme and Duplicate It: Decide whether
theme-lightortheme-darkis a closer starting point for your design. Then, duplicate the folder and give it a new name.# Example: Creating a new theme based on theme-light cp -r resources/scss/theme-light resources/scss/theme-my-awesome-themeCustomize Your Theme's Variables: Now you can go through the
.scssfiles in your newtheme-my-awesome-themefolder and adjust the CSS variable values to match your design requirements.Configure Your Application: In your
neo-config.json, add your new theme to thethemesarray. The first theme in the array becomes the default theme for the application.{ "themes": ["neo-theme-my-awesome-theme", "neo-theme-light"] }Note: The
neo-prefix is required, and your folder name should not include it (e.g., foldertheme-foobecomesneo-theme-fooin the config).Build Your New Theme: Run the full theme build process to compile the SCSS for your new theme and, critically, to update the
theme-map.jsonfile to include it.npm run build-themes
8. Theming in a Workspace
While the concepts above apply everywhere, it's important to understand where you should place your custom theme files when developing your own application. For this, Neo.mjs uses a workspace structure, typically created with npx neo-app.
A workspace mirrors the main neo.mjs repository structure, including its own resources/scss directory. This allows you to add themes and styles for your custom applications without modifying the engine's source code (which is included as an npm dependency in node_modules).
The SCSS Merge Mechanism
The most powerful feature of workspace theming is how the build scripts work. When you run npm run build-themes from your workspace, the script intelligently merges the SCSS files from your workspace's resources/scss directory with the ones from node_modules/neo.mjs/resources/scss.
The workspace's files act as an overlay, giving you fine-grained control.
This enables several powerful workflows:
Override Specific Variables: To change just a few variables for an existing theme (e.g.,
theme-dark), you only need to create a file at the corresponding path in your workspace (e.g.,my-workspace/resources/scss/theme-dark/button/Base.scss) and redefine only the variables you want to change. The build script will merge your changes with the original theme file from the engine.Create an Entirely New Theme: You can create a brand new theme folder (e.g.,
my-workspace/resources/scss/theme-corporate) inside your workspace. By creating SCSS files that match the paths of the framework components, you can provide a complete set of CSS variable definitions for your theme. The build script will discover and compile your new theme, allowing you to build a unique look and feel from the ground up without ever touching the engine's source code.Style App-Specific Components: If you create a component that is only used within a single application (e.g.,
my-workspace/apps/my-app/view/MyComponent.mjs), you can create its structural styles in your workspace atmy-workspace/resources/scss/src/apps/my-app/MyComponent.scss. The build script will pick it up and process it just like a core component.Style Workspace-Shared Components: For components intended to be shared across multiple apps in your workspace, you can create them in the workspace's main
srcfolder. These components must use theNeonamespace (e.g.,my-workspace/src/component/MyWorkspaceWidget.mjsdefiningNeo.component.MyWorkspaceWidget). You can then provide their structural styles in the corresponding path within your workspace'sresources/scss/srcfolder (e.g.,my-workspace/resources/scss/src/component/MyWorkspaceWidget.scss).
This overlay approach is extremely powerful. It lets you maintain a clean separation between your application code and the framework, making engine upgrades significantly easier.
9. The Build Process
To compile the SCSS files into the CSS that the browser uses, Neo.mjs provides two main build scripts.
build-themes
The npm run build-themes command is the main script for a full theme build. It:
- Compiles all
.scssfiles inresources/scssinto.cssfiles. - Uses
postcssto add vendor prefixes (autoprefixer) and minify the CSS (cssnano) for production builds. - Places the output into the
dist/<environment>/css/directory. - Generates a critical file:
resources/theme-map.json.
The theme-map.json file creates a mapping between every class name and the themes that have custom styles for it. This file is the key to the lazy loading mechanism.
watch-themes
For development, you can use npm run watch-themes. This script will watch the resources/scss directory for any changes and recompile only the file that was changed. This provides a much faster feedback loop when you are developing themes.
Important Note: The current version of watch-themes only handles changes to existing files. It does not detect new files, renamed files, or deleted files. As a result, if you add, move, or delete SCSS files while the watcher is running, the theme-map.json will not be updated, which can lead to inconsistencies. To apply these kinds of changes, you can run a full npm run build-themes command in a separate terminal. Enhancing the watch script to handle these cases is a planned improvement.
9. Lazy Loading in Action
You do not need to manually include any theme CSS files in your application's index.html. The engine handles it automatically.
Here's how it works:
- When the application starts, the
worker.Apploads thetheme-map.jsonfile. - When a component is about to be created, the engine checks the
theme-map.jsonto see if the active theme has a specific CSS file for that component. - If it does, it sends a message to the
main.addon.Stylesheet(in the main thread) to dynamically create a<link>tag for that CSS file and add it to the document's<head>. - The browser then loads the CSS file.
This process ensures that you only ever load the CSS that is actually needed for the components currently in your application, which can significantly improve initial load times.
VDOM Updates and Style Loading
The lazy loading of styles is tightly integrated with the rendering engine to prevent a "flash of unstyled content" (FOUC) and unnecessary layout recalculations.
Imagine you are showing a complex component, like a grid, for the first time. The engine will trigger the lazy loading of the grid's theme CSS. If a VDOM update for the grid were to proceed immediately, the browser might render the grid's DOM structure before its styles have arrived, causing a flicker or a jarring layout shift once the styles are applied.
To prevent this, the updateVdom() method in src/mixin/VdomLifecycle.mjs contains a crucial check. It looks at the Neo.worker.App instance to see if any theme files are currently being loaded (countLoadingThemeFiles > 0). If they are, it will pause the VDOM update for the component and listen for a themeFilesLoaded event. Once all pending CSS files have been loaded, the VDOM update is automatically resumed.
This elegant mechanism ensures that a component's DOM is only mounted or updated after its required styles are in place, leading to a smoother and more professional user experience.