Files
small-packages/luci-theme-footstrap/docs/third-party-apps.md
T
2026-09-20 01:22:33 +08:00

26 KiB
Raw Blame History

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/luci apps 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-ssclash checks it first;
  • the brightness of getComputedStyle(document.body).backgroundColor — ssclash (fallback), OpenClash (isDarkBackground), passwall (four copies of a YIQ function). OpenClash then stamps data-darkmode on our :root itself;
  • @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, OpenClash 999999 !important, dockerman/diskman overlays at 20000, podkop's toast container at 9999 in document.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 to tomorrow_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:

  1. 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.
  2. 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.
  3. 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 the orange fallback. 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-color on 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.

  1. header.ut — the markup. A chrome root marks itself with data-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.
  2. fs-sheets.js — the fence. CHROME_FENCE is 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.
  3. theme/10-chrome.css — the pin. It closes the one way in a fence cannot: inheritance from html/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-label its nowrap and forcing text-align from start to left on 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], …)) — &:hover names 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 inside fenceRules() 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.js had 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: white on a passwall modal, justclash's #1e1e1e terminal, 167 colours baked into luci-app-statistics PNGs) 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:

  1. open an app with ACE/CodeMirror (ssclash) → SPA to a neighbouring tab → back. The editor must still be alive (rule 1);
  2. open File Manager → go to System. Save/Apply/Reset must still be there (rule 2);
  3. visit podkop three times over SPA. The number of <style> elements in <head> must not grow (rule 3);
  4. 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-detector is 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-smartdns is 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 bare table { … }. 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, not insertRule() 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.