26 KiB
Living with third-party luci-app-*
What foreign apps actually do to a shared LuCI document, and the rules the theme follows because of it. Written for someone changing the theme.
Rules for app authors live in luci-app-styling-guide.md, the outward-facing document the README links. Where the two disagree, that one is the source of truth.
Surveyed July 2026 against sources (GitHub tarballs) and on a live OpenWrt 25.12 router with Playwright: 20 apps — 6 proxy stacks (OpenClash, passwall, passwall2, ssr-plus, mosdns, vssr), 5 clash/mihomo wrappers (ssclash, podkop, justclash, homeproxy, nikki), 4 heavyweights (dockerman, diskman, istore, AdGuardHome) — plus stock
openwrt/luciapps as a baseline.The prompt:
luci-app-ssclash's config editor drew as a black rectangle with no text and blew the page up to 2 007 346 px. The cause was not the app but the theme, and it forced a rewrite of the whole theme↔app contract.
What a foreign app actually does
1. It injects CSS into <head>, and that survives an SPA navigation
A full page load throws such a <style> away with the document, so the app is right to treat it as
"mine for one page". Our router does not reload the document, and the CSS stays forever.
| App | What it puts in <head> |
When |
|---|---|---|
luci-app-filemanager (stock) |
.cbi-button-save/-apply/-reset { display:none !important }; the sbwml fork adds bare th/td/tr:hover/.table |
every render |
luci-app-filemanager (newer) |
:root { color-scheme: light dark; --light-bg:… } |
every render |
HexEditor.js (same package) |
:root { --clr-background:… } |
module eval |
luci-app-banip, luci-app-adblock |
a <link> to custom.css styling .cbi-input-text, .cbi-input-select |
module eval |
luci-app-olsr-viz |
.label { color:black; background:white } |
every render |
luci-app-openvpn (stock) |
h4 { white-space:nowrap; … } |
every render |
luci-app-podkop |
4 KB: #cbi-podkop-*, .centered, .rotate, .skeleton, .toast* |
every render, no dedup |
luci-app-mosdns |
three <link>s to CodeMirror 5 |
every render |
ACE (luci-app-ssclash and anyone shipping ace.js) |
ace_editor.css (14 KB), error_marker.css |
module eval |
The distinction that matters is not <style> vs <link> — it is who can put the CSS back:
- injected in render (filemanager, podkop): the app will repeat it on the next render;
- injected at module eval (ACE, HexEditor, banip, adblock): it will never repeat, because the module is cached for the life of the document. Deleting such a sheet breaks the app until a page reload. That is what happened to SSClash.
Separately: a <style>/<link> inside the view's tree (package-manager, nftables, aria2,
v2raya, nlbwmon) is harmless — it dies with the contents of #view.
2. It sniffs dark mode, in four different dialects
There is no standard, so apps guess:
[data-theme="dark"]on:root—luci-app-justclash(21 rules);:root[data-darkmode="true"]—luci-app-internet-detector(and OpenClash writes it);data-bs-theme(a Bootstrap 5 convention) —luci-app-ssclashchecks it first;- the brightness of
getComputedStyle(document.body).backgroundColor— ssclash (fallback), OpenClash (isDarkBackground), passwall (four copies of a YIQ function). OpenClash then stampsdata-darkmodeon our:rootitself; @media (prefers-color-scheme: dark)— netspeedtest, vssr, passwall, ustreamer. That is the OS, not the theme: a user with a dark theme on a light OS gets light cards on a dark page. Unfixable in CSS, by construction.
3. Everything else that breaks, or could
- z-index wars: passwall
2147483647, OpenClash999999 !important, dockerman/diskman overlays at20000, podkop's toast container at9999indocument.body. - Global resets: OpenClash
*{margin:0;padding:0}(twice), vssr@import "pure.css". - Generic class names claimed:
.hidden,.label,.description,.centered,.skeleton,.toast,.flex-row,[hidden]{display:none!important}(luci-lib-taskd). - Viewport media queries (justclash 56rem, podkop 900px, netspeedtest 768px): our content column is narrower than the viewport by the sidebar's width, so their "mobile" layout switches on later than it should.
- Hard-coded dark editors: mosdns and AdGuardHome nail CodeMirror to
dracula, ssclash nails ACE totomorrow_night_bright. The editor stays black on a light page — and on bootstrap too, so it is not our regression. - Legacy Lua/CBI is still the majority (OpenClash, passwall(2), ssr-plus, vssr, dockerman, diskman, AdGuardHome, istore). Full reload on every click, and all their CSS lives exactly on their page.
The theme's rules
Rule 1. View CSS is NEVER deleted
Deletion is irreversible: a sheet imported at module eval does not come back. Symptom: a structureless black ACE and a 2-million-pixel page.
Rule 2. A document holding an "invasive" sheet hands the next page over by full load
A sheet is invasive if it could paint somebody else's page. Three tests, all on the facts rather
than on a list of names — the set of theme names and custom properties is read out of cascade.css
at runtime, 0.3 ms per transition:
- A bare selector — no class, id or attribute (
pre,*,svg text,div > label + select). It hits stock markup by construction. Exception: if every declaration is a custom property the theme does not read (:root { --app-temp-status-temp: … }), there is nothing for it to paint — inert. But:root { color-scheme: light dark }(stock filemanager) is a standard property, and it re-points every UA widget in the document at the OS setting. - A stock name with no pin — a selector made entirely of names the theme knows
(
.cbi-button-save,.label,.error,.cbi-input-text). It has nothing to hold it to the app, so it matches the same widgets on every page. Not invasive:#cbi-podkop-section > .cbi-section-remove,.bandix-table th.sortable.active,.device-toolbar .cbi-input-select— the stock class is pinned by the app's own name and cannot match without its markup. Functional pseudo-class arguments are discarded when looking for the pin:.cbi-button-save:not(.custom-save-button)mentions its own name inside a negation, which is an exclusion, not a scope, so the sheet stays invasive. - An unreadable sheet (
<link>still loading, 404, cross-origin) counts as invasive — the conservative answer is slow, not broken.
How that lands on the router's actual apps:
| SPA preserved | Full load on leaving |
|---|---|
ACE/ssclash (.ace_*) |
filemanager (.cbi-button-save:not(…)) |
podkop (#cbi-podkop-*) |
stock openvpn (h4 { white-space: nowrap }) |
bandix tables, hex editor (.hexview) |
bandix (.error { … } — a global class) |
| the overview CPU include | wrtbwmon (div > label + select { min-width }) |
temp-status: its :root { --app-* } |
temp-status: its own svg text { fill } |
Implementation: fs-sheets.js → themeNames(), inertDeclarations(), invasiveSheet(),
documentPoisoned(). It covers both <style> and <link rel=stylesheet>; [data-fs-shell] and
everything inside #view are excluded. Full detail: spa-router.md.
fs-sheets.js also re-declares the layer order as a new <style>, first child of <head>,
because a foreign sheet landing ahead of cascade.css inverts the cascade layers — see
css.md.
Rule 3. The only safe deletion is a byte-identical duplicate
dedupeViewSheets(). The rules do not disappear (the first copy stays), and a library's "have I
imported this?" check still finds its sheet. Without it, podkop accumulates one <style> per visit.
Rule 4. Dark mode is announced in three dialects
data-darkmode (read only by the theme itself) plus data-theme and data-bs-theme for outbound
compatibility. styles/ may never read the latter two — the same boundary as the export tier.
tools/axes.mjs holds it: the pre-paint in head.ut and the live stampDark() must stamp the same
set.
Rule 5. body must have an OPAQUE background of the right brightness
This is what every brightness sniffer reads. A transparent body (background moved to :root or a
pseudo-element) makes OpenClash decide "light" — its /rgb\(/ regex does not even match
rgba(0,0,0,0) — and repaint our dark page in its light palette. tools/export-tier.mjs holds it
across the whole palette × mode × tint matrix.
Rule 6. The export tier is a contract, and it has to be a ramp
Apps read --primary-color-*, --text-color-*, --background-color-*, --border-color-* with a
literal fallback. The export surface is exactly 26 names, and the warning family is called
warn: --warn-color-high/-medium/-low and --on-warn-color.
Two findings that used to be filed as one:
- podkop's
var(--warn-color-medium, orange)(7 uses) WORKS. The name is correct and resolves to the theme's ochre. There is no--warning-color-*anywhere in the tree, so "fixing" podkop to use that would drop all seven declarations into theorangefallback. An earlier revision of this document claimed the opposite; that was wrong. - justclash's
var(--text-color, inherit)(6 uses) is a real typo. There is no level in the name and no such property; the correct one is--text-color-high. Do not invent--text-coloron our side: that fixes somebody's typo at the price of a name we would then support forever.
The private --fs-* tier (~125 properties) is not part of the contract and is not addressed to
apps — which is exactly why it is separated. See design-system.md.
Rule 7. The chrome is fenced off, in three places that must agree
An app's unlayered !important outranks every cascade layer, so the chrome cannot be defended by
the cascade. It is defended by not matching, in three places — and npm run chrome-fence is
what keeps them agreeing. Proven rather than assumed: breaking the fence constant to
.fs-sidebarTYPO left the menu completely unprotected while npm run check, jsmin-verify and
eslint all exited 0.
header.ut— the markup. A chrome root marks itself withdata-fs-chrome. The<nav>is one; so are the skip link, the two sr-only elements and the command palette, none of which sits inside it. The mark is what the other two read.fs-sheets.js— the fence.CHROME_FENCEis appended to a foreign selector's subject so it can no longer match a chrome element. That is what beats a third party's!important: there is nothing left to outrank.theme/10-chrome.css— the pin. It closes the one way in a fence cannot: inheritance fromhtml/body, where no match is needed at all.
The gate derives the mark from the markup and never restates it: rename it in header.ut and
the gate re-derives it, then fails on the two copies still saying the old name. The failure it
prevents has no symptom — the fence silently stops fencing, every test stays green, and the menu
breaks on someone else's router months later.
The fence and the pin are each one canonical string and are compared whole rather than tested
for tokens. That is not pedantry: the token version's four independent includes() checks all
passed on :where(:not(.fs-sidebar), .fs-sidebar *) — a plausible botched edit that is the exact
inverse of a fence, targeting the chrome instead of sparing it.
The shapes both strings encode, each a measured bug:
:where()in both, because it contributes zero specificity. Drop it from the fence and every app rule silently gains a point, re-ordering the app's stylesheet against itself on its own page. Drop it from the pin and the pin starts fighting the chrome's own rules on source order.- The fence must cover the root AND its subtree (
[m], [m] *); the root alone leaves every menu element inside it exposed. - The pin must cover the root ALONE, and it says so itself (
:not([m] *)) rather than trusting that nobody ever nests a mark. Pinning descendants was measured and it broke the chrome's own inheritance — a direct declaration beats an inherited one even when the inherited one is ours, costing.fs-labelitsnowrapand forcingtext-alignfromstarttolefton 302 elements, which breaks every RTL language LuCI ships. - The pin may carry only inherited properties. A non-inherited one there is a style decision wearing a guard's coat, and at zero specificity it would lose to everything anyway.
The fence is CSSOM surgery, not a CSS parser — which is what makes an app written in modern CSS
a non-event rather than the next round of cat-and-mouse. Every selector the fence reads comes out of
the browser's own parser (CSSStyleRule.selectorText), and every one it writes goes back in through
the same parser's setter, which is atomic: a selector it cannot parse leaves the rule exactly where
it was. The only text this file ever scans is one already-serialised selector, and only to answer
two questions on it — where the top-level commas are, and where the :: is. Nesting, :has(),
:is(), @scope, @container and an app's own @layer are the parser's problem, not ours; the
recursion that reaches them (r.cssRules) is three lines and does not care which at-rule it just
descended into.
Measured rather than argued, on owrt2512 (25.12.4) against the live theme: 21 hostile sheets, one
per syntax, each carrying padding: 0 !important on an unpinned selector, injected into <head>
after the theme's own modules had loaded — *; a selector list; nesting with &, without & and
two levels deep; :has(); :is()/:where(); :not(:has()); bare @scope and @scope … to …;
@supports; @media; the app's own @layer; an escaped leading digit and an escaped @; a comma
and an IPv6 :: inside an attribute value; a pseudo-element; and two sheets built entirely through
insertRule() (empty text, so textIsSheet() refuses the @layer wrap and only the fence stands).
0 of 247 chrome elements moved in any of the 21, on both Blink and WebKit. The negative control
is the same payload marked [data-fs-shell] so the module skips it: 63 elements flattened (59
for the nested variant), which is what makes the twenty-one zeroes a working fence rather than a
broken measurement. Gecko (Firefox 153) rewrites all of them identically on a synthetic page.
Two things that fell out of it and are worth knowing:
- A nested rule under a pinned parent is fenced too, redundantly.
#app-x .thing { &:hover { … } }comes back as&:hover:where(:not([data-fs-chrome], …))—&:hovernames nothing the theme does not know, so it reads as unpinned on its own. It costs nothing (the&still requires#app-x, which can never be chrome, and:where()adds no specificity) and the safe default is the right one: a nested rule whose parent is itself unpinned MUST be fenced, and from insidefenceRules()the two cases look the same. Every other shape round-trips byte-identical. - The gap the fence does not cover is time, not syntax. A first attempt at the measurement
injected on a fixed 2 s timer and reported 121 of 247 elements flattened on WebKit — the stand is
slow enough that the payload landed before
fs-sheets.jshad loaded and started watching<head>. That is the flash the module's own comment already describes for server-rendered<link>s, it affects only the page you are on, and it is not fixable from a module that has to be fetched. Any future probe must wait for the theme's JS, or it measures the gap and calls it a fence failure.
The same gate holds the dark-mode guard to stampDark: that guard exists because third parties
write the attributes this theme publishes (luci-app-openclash does it in seven templates). Add a
fourth dialect to stampDark and forget the observer's attributeFilter, and that dialect is
unguarded — silently.
Rule 8. A foreign table is claimed by its HEADER ROW, not by its class
.table is LuCI's class, and every table rule the theme has hangs off it — the frame, the cell
padding, the measured card stack. An app that emits a bare <table> matched none of it, so nothing
tagged it, nothing measured it and .fs-main's overflow-x: clip silently CUT its right-hand
columns; the only thing that ever reached it was a phone-tier scrollbar. tagDataTables() now takes
any table under a content root — #view or the #modal_overlay an app's dialog is built in — that
has a header row in any of four markups, adds .table.fs-dt, and copies the
column headings into data-title so the card has something to print. A table with no header row
is left alone on purpose and scrolls: see css.md.
The claim is re-made on every fit pass, not once per element. These tables are polled: the app
replaces the rows inside the element it already has, so a claim that ran once left every batch after
it without .tr/.td and without a caption — on a table that may by then be carding, where
#view .table.fs-dt.fs-stacked { overflow: hidden } clips the fresh rows with no scrollbar. Both
halves are additive, and the one JUDGEMENT — "does this table speak LuCI's row vocabulary, or is it
ours to rewrite?" — is still taken once, at claim time, and remembered on the element, so LuCI's own
markup is never rewritten by a later pass.
Each half answers the common case before it walks anything, because a fit pass is a mutation
pass, not a once-a-second one. The adoption returns on its memo. The captioning skips per row:
a row whose first cell already carries a caption has been walked, so on everything LuCI renders —
where ui.Table writes data-title as it builds — the pass costs one attribute read per row and
nothing else. Measured on Processes, 114 rows and 678 cells: 0.134 ms per pass for the full walk
against 0.053 ms with the per-row skip. Same tagged, carded and captioned counts as before the
change, and no long task over eight poll ticks.
The question is asked of the ROW because no single row speaks for the table. A table-level probe has
to pick one, and every choice is wrong for some shape: asking the last row stalls forever on a
<tfoot> of per-column totals, which takes captions on the first pass and is never bare again while
a poll keeps replacing the <tbody> above it — reproduced on a live table, where the fresh rows came
back with no caption at all. Per row there is nothing to guess, and cells replaced inside a
surviving row arrive bare and are walked like any other.
This is deliberately unmeasurable here. A census of #view table:not(.table):not(.cbi-section-table)
over all 196 menu pages on the stand — openclash, justclash, ssclash, dashboard and statistics
included — found zero: everything anyone here emits already carries the class. The rule exists for
the shape a census cannot reach, which is what the coverage contract means
(conventions.md). The three synthetic shapes are checked instead, on Blink and
WebKit at 1440 px and 390 px: <thead><tr><th> and <tr><th> both card with the right captions, a
header-less matrix stays a scrolling matrix at both widths.
Rule 9. A table inside the app's OWN scroller is the app's business
The fit pass cards a data table when it needs more width than it has been given. Inside a box the app scrolls itself, that question has already been answered by somebody else, and every answer the ladder can give overrules it: within a 598px box a wide table overflows by definition, so the remedy ends in a card stack however much room the page has around it.
Measured on luci-app-filemanager, which parks its listing in #file-list-container.resizeable
(resize: both; overflow: auto, and no width of its own, so the browser's default box is what the
reader gets): the listing came out as cards on a 1280px screen with 1224px of column beside it.
Carded on the 0.14.3 package too, so this is not a regression of the fit rework — it is a case the
rule never covered.
fit.inScroller() walks from the table to the content root and answers whether any ancestor scrolls
horizontally; fs-select.js asks BEFORE it applies the slot's remembered answer, so a table carded
by an older build is un-carded rather than carded again. The walk stops at #view and
#modal_overlay on purpose: the dialog's own scroller is the theme's (base/60-modal.css), and the
scroll fallback the theme gives a foreign table is on the TABLE itself (theme/30-tables.css), not
on an ancestor — neither may switch this rule on for everything.
Swept over all 95 menu pages of a stand at 390px: one table matches, the file manager's. No stock page has a data table inside a scroller of its own, so nothing that cards today stops carding.
What the theme cannot do, and should not try
@media (prefers-color-scheme)in an app's CSS is overridden by nothing — it is an OS setting. The only mitigation is that our default is Auto, which follows the OS.- Hard-coded literals (
background: whiteon a passwall modal, justclash's#1e1e1eterminal, 167 colours baked intoluci-app-statisticsPNGs) are not our bytes. - An app's z-index on its own page is its own page.
- An app's viewport media queries do not know about our sidebar.
And what the claim buys it, since 0.12.6. A claimed table is measured like any other data table,
which means it gets the whole ladder: honest column floors while it is a table, the expendable
columns (hide-xs/hide-sm) dropped by measurement rather than at 767px, one column allowed to
shred before the table gives up on being a table, and the card as the last rung. A table that has
no header row is never claimed — it cannot card, because a card prints data-title and it has
none — and instead scrolls inside itself if it is measured to overflow, with its first column
pinned, a tab stop and an accessible name.
A foreign table that holds a widget is refused the scroll, and an app author should know why:
overflow-x: auto computes overflow-y to auto as well (css-overflow-3 §3.1), so a scrolling
table clips every absolutely positioned thing inside it, and luci-base's openDropdown() sizes an
open list against the nearest scroll parent — which would be the table. If your table holds a
ui.Dropdown, a dynlist or a tooltip container, the theme leaves it alone rather than break the
control.
How to check
npm run check # includes axes (rule 4) and export-tier (rules 5, 6)
On a live router, the scenario that would catch everything above:
- open an app with ACE/CodeMirror (ssclash) → SPA to a neighbouring tab → back. The editor must still be alive (rule 1);
- open File Manager → go to System. Save/Apply/Reset must still be there (rule 2);
- visit podkop three times over SPA. The number of
<style>elements in<head>must not grow (rule 3); - justclash in dark mode: its
[data-theme="dark"]rules must match (rule 4).
Appendix: the second ten by stars
quickfile, tailscale-community, smartdns, internet-detector, 3ginfo-lite (client JS — brought up on the router and rendered) plus cloudflarespeedtest, aliddns, koolproxy, ech-workers, sms-tool (legacy Lua — read).
All ten preserve SPA navigation, and none injects anything that could paint someone else's page.
luci-app-internet-detectoris the only one of the ten that injects into<head>at all, at module eval::root { --app-id-* }(its own prefix, so inert by our rule) plus its own.id-*classes. Its dark block hangs on:root[data-darkmode="true"]— our attribute — so dark mode works for it out of the box. A fourth detection dialect, and we already stamp it.luci-app-smartdnsis the model:E('style', [css])returned inside.cbi-map, so it dies with the view.- Legacy Lua apps put
<style>in the page template, including baretable { … }. It cannot leak (the page is server-rendered and SPA never enters), but light surfaces stay light on a dark theme — unfixable from outside.
The rules above cover the entire second ten with no theme change. Breakage is concentrated in the big old apps, not in the mass.
Appendix: luci-app-podkop, the app the fence was tested against
Checked against itdoginfo/podkop@main sources, not from memory: the injected CSS was assembled
from its template literals and replayed on a live router exactly the way the app does it.
What it does right:
- One injection point, one
<style>in<head>. Not a<link>from the template, notinsertRule()in pieces. - Names in its own space: 30 of 40 selectors carry the BEM prefix
pdk_dashboard-page__…, the rest are pinned to its own ids. Our "pinned to the app" signal is a name the theme does not know, and it is present in nearly every rule. - It reads the export tier, not our private tokens:
--background-color-low,--success-color-medium,--warn-color-medium,--error-color-medium,--primary-color-high,--primary-color-low,--text-color-high. All seven are defined and follow the mode.
Our verdict on its real CSS: documentPoisoned() is false, 0 chrome elements damaged, the
sheet is not rewritten. So SPA is not disabled on podkop's pages and the theme leaves its styling
alone. That is the "we are not over-clamping" check: the judge was tightened, and the model app
still passes untouched.
What stayed generic, and why it is survivable: .centered, .rotate, .skeleton, .toast*
have no prefix. The theme does not style them, so by our test they count as pinned to the app and
are left alone. That is luck, not protection — had the app called a class .center or .table, it
would have landed in the shared vocabulary. Advice to authors: prefix every name, helpers
included.
What we borrowed from it: the loading placeholder. podkop draws a skeleton while its data arrives; our SPA had the same hole on a cold route. See spa-router.md.