Where does the theme request stop?
Follow the stylesheet. The browser requests the theme CSS from the Gateway. The Gateway answers from the Perspective theme manager, not directly from disk. The theme manager builds that content when the Gateway file watcher reports a change in the themes directory. The stack trace shows each hop:
| Hop | Component in stack trace | Role |
|---|---|---|
| 1 |
FileWatcher.processFileChanges / processWatchEvent
|
Detects the save to light.css or dark.css
|
| 2 | AbstractAssetManager.onCreateOrModify |
Hands the modified asset to the theme manager |
| 3 | ThemeManagerImpl.onCacheChanged |
Iterates every cached theme (HashMap.forEach) |
| 4 | ThemeManagerImpl.processTheme |
Resolves the root theme file and its imports |
| 5 | ThemeManagerImpl.removeComments |
Strips comments from the resolved CSS string; throws here |
The request stops at hop 5. The exception text is Cannot invoke "String.replaceAll(String, String)" because "css" is null, so processTheme passed a null string to removeComments. The theme manager produced no CSS content for the theme. The browser then gets a 404 on the theme stylesheet because the Gateway has no processed content to serve.
A null content string usually means the root file could not be read from disk. That is the first thing to rule out. In this failure, the file was readable. The same file loads with a plain @import and fails only when layer(ignition) is added. The trigger is the import syntax, not file access.
Check before moving on: open the Gateway log and search for ThemeManagerImpl.removeComments. If the NPE appears within seconds of saving a theme file, the file watcher picked up the change and the processing failed. You are looking at this failure and not a stale cache.
Which edit turns a working theme into a 404?
Isolate the trigger with an A/B test on the root theme file. The file watcher reprocesses on save, so no Gateway restart is needed between steps.
Root file content (themes/light.css) |
Gateway log | Browser | Diagnosis |
|---|---|---|---|
@import "./light/index.css"; |
Clean | Theme CSS served, styles applied | Baseline good |
@import "./light/index.css" layer(ignition); |
NPE in removeComments
|
404 on theme stylesheet | Theme processor does not accept the layer() import modifier |
Revert to plain @import
|
Clean on next scan | Theme returns | Confirms the modifier is the sole trigger |
| Root file missing, renamed, or permission-denied | NPE in removeComments
|
404 | Real disk-read failure; fix path or file ACLs |
The last two rows log the same exception, so the log alone cannot tell them apart. The A/B test decides. If a plain @import also fails, fix the file path or OS permissions on the themes directory first.
A second effect matters on a shared Gateway. onCacheChanged processes all themes inside HashMap.forEach. An uncaught exception inside a Java forEach ends the loop. Themes that come after the broken one in that iteration may not be refreshed on that pass. If other themes act stale after the NPE, clear the broken import first and then re-check them.
How do you restore a working baseline?
- Open
modules/com.inductiveautomation.perspective/themes/light.css(anddark.cssif you edited it) under the Gateway data directory. - Remove the
layer(ignition)modifier so the line reads@import "./light/index.css";. - Save the file. The file watcher triggers a reprocess on its own.
- Watch the Gateway log for about one scan cycle. Confirm that no new
ThemeManagerImplexception appears. - Hard-reload a Perspective session in the browser. Clear the cache so an old 404 is not reused.
Check before moving on: in browser developer tools, open the Network tab and filter on CSS. The theme stylesheet must return HTTP 200 and contain the rules from light/index.css. Do not start layering work until the baseline serves cleanly. Otherwise you cannot tell a new failure from the old one.
Long term, stop editing the shipped light.css and dark.css. Gateway upgrades can overwrite files that ship with the module. Put your changes in a custom theme with its own root file and folder in the same themes directory. That also keeps the stock themes as a known-good fallback.
Why does the native theme still beat your layered styles?
You need the cascade rule before you can choose a workaround. The goal was to declare:
@layer ignition, theme, base, components, utilities;
In that statement, layers declared earlier have lower priority. ignition comes first, so it would be the weakest layer, and utilities the strongest. That part is correct.
The catch is unlayered CSS. For normal (non-!important) declarations, any unlayered rule beats every layered rule, whatever its specificity. The stock Perspective theme is unlayered. Your custom CSS goes into theme, base, components, or utilities. So the native theme wins every conflict, and even a high-specificity selector in utilities loses to a low-specificity native rule.
| Native theme | Custom CSS | Who wins on conflict (normal declarations) |
|---|---|---|
| Unlayered | Layered | Native, always |
| Unlayered | Unlayered, loaded later | Custom at equal or higher specificity; otherwise specificity decides |
In ignition layer |
In later layers | Custom, regardless of specificity |
| Any layer | Any layer, !important
|
Layer order reverses: earlier layers win for important declarations |
That is why layer(ignition) on the import looked like the fix: it moves the native theme into the weakest layer. The theme processor rejects that syntax, so you have two supported routes. Put the native rules into a layer another way (next section), or keep your overrides unlayered (the section after).
How do you put native rules in a layer without layer() on @import?
Use a @layer block inside the imported files instead of a modifier on the import line. An @layer name { ... } block is ordinary stylesheet content. @import "..." layer(name) is import-line syntax, and that is what the theme processor fails to handle.
Two CSS constraints shape this:
-
@importrules cannot sit inside a@layerblock. You cannot wraplight/index.cssas a whole if it is made mostly of imports. Wrap the leaf files, meaning the files that hold actual rules. - A
@layerordering statement may come before@importrules. It is the only at-rule besides@charsetthat may do so.
Build it in a custom theme so the shipped files stay untouched. The theme name mytheme below is a placeholder:
/* themes/mytheme.css (root file) */
@layer ignition, theme, base, components, utilities;
@import "./mytheme/native/index.css";
@import "./mytheme/custom/index.css";
/* each leaf file copied under mytheme/native/ that contains rules */
@layer ignition {
/* original rules, unchanged */
}
/* each leaf file under mytheme/custom/ */
@layer components {
/* your rules */
}
- Copy the native theme folder into the custom theme folder.
- Fix relative paths in every
@importandurl()reference. Font and image paths break silently when a folder moves one level deeper. - Wrap the rule-bearing leaf files in
@layer ignition { }. - Add the ordering statement as the first line of the root file.
- Save and watch the Gateway log. The theme processor may also reject the ordering statement or the
@layerblocks. If an NPE appears, remove the most recent change and fall back to the unlayered approach below.
Check before moving on: the custom theme stylesheet returns HTTP 200. In the developer tools Styles pane, a styled component shows its native rules under the ignition layer. The browser's cascade-layers view lists ignition first and utilities last.
What if the theme processor rejects @layer syntax entirely?
Drop layers from the theme pipeline and use source order plus specificity. This approach uses only plain @import lines, and the baseline test already showed the processor handles those.
/* themes/mytheme.css (root file) */
@import "./light/index.css";
@import "./mytheme/overrides.css";
Import the overrides last. Both files are unlayered, so on a conflict with equal specificity the later file wins. When a native rule is more specific than your override, match or exceed its selector specificity. Read the winning native selector from the Styles pane rather than guessing. Use !important only as a last resort. It does not layer cleanly, and it is hard to override later.
If you need true layer ordering among your own style groups (theme, base, components, utilities), keep that layering inside your own files. Then add one small unlayered override file for any conflicts with the native theme. Your layers keep their order among themselves, and the unlayered file settles conflicts with the native theme.
Check before moving on: pick one property the native theme sets and your override changes, such as a component background. Confirm that the computed value in developer tools matches your override, and that the Styles pane shows the native declaration crossed out.
How do you verify the fix end to end?
Retrace the path from disk to rendered pixel, one hop at a time.
| Hop | Check | Pass condition |
|---|---|---|
| Disk | Root file and all imported files exist at the paths referenced | No broken relative paths after copying |
| File watcher / theme manager | Gateway log after saving each theme file | No ThemeManagerImpl or removeComments exception |
| Other themes | Switch a session to light, then dark, then the custom theme |
All three load; none are stale |
| HTTP | Network tab, CSS filter, hard reload | Theme stylesheet returns 200, not 404 |
| Cascade | Styles pane / layers view on a styled component | Layer order matches the ordering statement, or the unlayered override wins |
| Render | Computed style of the target property | Value comes from your custom rule |
| Clients | Repeat the render check in every client type that runs Perspective sessions on site | Same computed values; embedded browsers that lack cascade-layer support drop @layer blocks, so check each one |
As the last step, save a trivial change, such as a comment line, to the custom root theme file. Then confirm the whole chain once more: the log stays clean, the stylesheet returns 200, and the computed style holds after a hard reload.
FAQ
Why does adding layer() to a Perspective theme @import cause a NullPointerException?
The theme manager produces no CSS content for the root file when the import carries the layer() modifier. It then passes a null string to ThemeManagerImpl.removeComments, which calls replaceAll on it and throws. Removing layer(ignition) and saving restores normal processing on the next file-watcher scan.
Why does the Perspective theme return 404 in the browser after editing light.css?
The Gateway serves theme CSS from the theme manager's processed cache, not straight from disk. If processing throws, as with the removeComments NPE, no content exists to serve and the browser gets a 404. Check the Gateway log for a ThemeManagerImpl exception right after the save.
Why do my CSS cascade layers not override the Ignition Perspective theme?
For normal declarations, unlayered CSS beats all layered CSS regardless of specificity, and the native Perspective theme is unlayered. Either wrap the native rules in @layer ignition { } blocks inside the leaf files of a custom theme, or keep your overrides unlayered and import them after the native theme.
How do I tell a file-read failure from a syntax problem when the theme NPE appears?
Run an A/B test. Reduce the root theme file to a plain @import "./light/index.css"; and save. If the NPE clears, the import syntax was the trigger. If it persists, check the file path, the file name, and OS permissions on the themes directory.