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

404 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](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](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](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](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](css.md#which-of-the-three-a-table-gets-and-what-decides-it).
**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](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
```sh
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](spa-router.md).