summaryrefslogtreecommitdiffstats
path: root/themes/luci-theme-footstrap/htdocs/luci-static/resources/fs-sheets.js
blob: 1b108ccf1c64306de3fb7a9b80bc9eddc48b988c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
'use strict';
'require baseclass';

/* ---- a view's injected CSS: never DELETE it; leave a poisoned document to a real load ----
 *
 * A view's <style> dies with the document on a full load, but SPA nav never reloads, so it restyles
 * every page after. `luci-app-filemanager` injects
 * `.cbi-button-save:not(.custom-save-button) { display: none !important }` — unlayered and
 * important, outranking every cascade layer, so one visit removes Save/Reset from every config page.
 *
 * Deleting such sheets on navigation is not the answer: a stylesheet only returns if its injector
 * runs again, and a library importing CSS at module eval never does (the module is cached for the
 * life of the document). ACE's 14 KB editor sheet is imported once, and after a sweep its editor
 * came back as a black rectangle two million pixels tall. Deletion is silently one-way.
 *
 * So: a sheet matching only its own app's widgets is inert elsewhere and is left alone. One
 * reaching into the widget universe the THEME styles can repaint any page, which spends the
 * document — the router then refuses to hand it to another view and falls back to a real page load,
 * trading speed and never correctness (fs-router.js owns that refusal; this module answers the
 * question).
 *
 * `invasiveSheet()` is that test, and its universe is read back from cascade.css itself rather than
 * from a hand-written list, so it tracks the theme; 0.3 ms per nav. Exempt: anything the server
 * marked `[data-fs-shell]` (partials/head.ut) and anything inside `#view`, which dies with the
 * content swap. LuCI core injects no <style> at runtime (checked: luci.js, ui.js, cbi.js). If
 * cascade.css cannot be read, every view sheet counts as invasive: fail to the slow path, never
 * the broken one. */
let _themeNames = null;

/* What counts as a NAME — a class or an id — in a selector: the vocabulary the invasive-sheet test
 * is written in. One copy, because widening it in the harvester alone puts names into `names` that
 * pinnedToApp() can never match, and a selector that does reach the chrome then reads as pinned and
 * is left unfenced.
 *
 * Shared safely because every use is String.match(): a /g regex is stateful under .test(), while
 * [Symbol.match] resets lastIndex first. Do not call .test() on this one. */
const NAME_RE = /[.#][A-Za-z_][\w-]*/g;

/* ---- a quoted value is data, and every scanner below would otherwise read it as syntax ----
 *
 * `[title="a,b"]` is one selector part carrying a comma, `[href*="("]` one attribute carrying an
 * unbalanced paren, `[data-x=".foo"]` names no class at all. Read literally each scanner gets a
 * different wrong answer: selectorParts() splits the part in two and the tail reads as unpinned,
 * fenceRules() rejoins it as `[title="a, b"]` and silently rewrites the app's own selector, and
 * stripPseudoArgs() drives `depth` into a hole and eats the rest of the selector.
 *
 * One masker answers it for all three. It replaces the CONTENT of every quoted string with spaces,
 * length-preserving 1:1, so a scanner can read the mask and still slice the ORIGINAL — the fence
 * must write back the app's own bytes. An escape and the character it escapes are both content, so
 * `\"` cannot close the string. */
function maskStrings(text) {
	let out = '', q = null;
	for (let i = 0; i < text.length; i++) {
		const ch = text[i];
		if (q === null) {
			out += ch;
			if (ch === '"' || ch === '\'') q = ch;
			continue;
		}
		if (ch === '\\') {
			out += ' ';
			if (i + 1 < text.length) { out += ' '; i++; }
			continue;
		}
		out += (ch === q) ? ch : ' ';
		if (ch === q) q = null;
	}
	return out;
}

/* a re-hosted <style>'s text is no longer what its app wrote, and dedupeViewSheets keys on the
 * original — otherwise the app's next identical copy stops looking like a duplicate */
const origText = new WeakMap();

function themeNames() {
	if (_themeNames) return _themeNames;
	const names = new Set();	/* every class and id the theme styles */
	const props = new Set();	/* every custom property it declares or reads */
	const walk = (rules) => {
		for (const r of rules) {
			/* masked like every other read of a selector: a `.foo` inside one of OUR quoted values
			 * would enter `names` as a name we style, and pinnedToApp() masks, so it could never
			 * match — see NAME_RE */
			if (r.selectorText)
				(maskStrings(r.selectorText).match(NAME_RE) || []).forEach((n) => names.add(n));
			if (r.cssText)
				(r.cssText.match(/--[A-Za-z_][\w-]*/g) || []).forEach((p) => props.add(p));
			if (r.cssRules) walk(r.cssRules);
		}
	};
	for (const ss of document.styleSheets) {
		if (!ss.href || !(/\/cascade\.css/).test(ss.href)) continue;
		try { walk(ss.cssRules); } catch (e) { return null; }
	}
	_themeNames = names.size ? { names, props } : null;
	return _themeNames;
}

/* Is this selector part held inside the app's OWN markup by a name the theme does not know?
 * `#cbi-podkop-section > .cbi-section-remove` is — podkop's section must exist for it to match, so
 * it can reach neither another page nor our chrome. A part made entirely of names the theme knows
 * (`*`, `.nav`, `ul.nav > li > a`) has nothing pinning it anywhere.
 *
 * Functional pseudo-class arguments are stripped before looking for the pin, which is the whole
 * difference between podkop and the file manager: `.cbi-button-save:not(.custom-save-button)` names
 * an app class inside a NEGATION, so it excludes the app's markup rather than requiring it.
 *
 * Shared by invasiveSheet() and fenceRules(), which must agree by construction: a part able to
 * reach another page is exactly a part able to reach the chrome. */
/* Split a selector list on its TOP-LEVEL commas. `String.split(',')` cannot: `:not(.a, .b)` is one
 * part carrying a comma, and both halves then reach pinnedToApp() as garbage — the tail keeps a
 * visible app name, so the file manager's motivating rule reads as pinned and is neither judged nor
 * fenced.
 *
 * Scans the mask and slices the original (see maskStrings): a comma inside `[title="a,b"]` is a
 * character in a value, and fenceRules() joins the parts straight back into selectorText. */
function selectorParts(text) {
	const scan = maskStrings(text);
	const out = [];
	let depth = 0, start = 0;
	for (let i = 0; i < scan.length; i++) {
		const ch = scan[i];
		if (ch === '(') depth++;
		else if (ch === ')') depth--;
		else if (ch === ',' && depth === 0) { out.push(text.slice(start, i).trim()); start = i + 1; }
	}
	out.push(text.slice(start).trim());
	return out.filter(Boolean);
}

/* Drop every functional pseudo-class argument, nesting included. A regex cannot: stopping at the
 * first `)` leaves `:not(:is(.app))` as a stray paren plus a `.app` that looks like a pin.
 *
 * Works on the mask, so a paren inside `[href*="("]` cannot drive `depth` into a hole and a `.foo`
 * inside an attribute value cannot read as the app's own pin. The output is read by NAME_RE and
 * never written back to the CSSOM. */
function stripPseudoArgs(part) {
	const scan = maskStrings(part);
	let out = '', depth = 0;
	for (let i = 0; i < scan.length; i++) {
		const ch = scan[i];
		if (ch === '(' ) { depth++; if (depth === 1) { out += ' '; continue; } }
		if (ch === ')') { depth--; continue; }
		if (!depth) out += ch;
	}
	return out;
}

function pinnedToApp(part, names) {
	return (stripPseudoArgs(part).match(NAME_RE) || []).some((n) => !names.has(n));
}

/* A rule with a bare selector (`:root`, `pre`, `*`) cannot touch us if none of its DECLARATIONS
 * can: a custom property this theme never reads is inert wherever it lands, which is the difference
 * between an app costing a full page load and not.
 *
 * Still invasive: any standard property on a bare selector (`:root { color-scheme: light dark }`
 * re-points every UA widget at the OS preference), and any custom property the theme reads — the
 * private `--fs-*` tier exists so an app writing `--accent` on `:root` cannot repaint us. */
function inertDeclarations(rule, props) {
	const st = rule.style;
	if (!st || !st.length) return false;	/* no declarations to judge -> judge by selector */
	for (let i = 0; i < st.length; i++) {
		const p = st.item(i);
		if (p.slice(0, 2) !== '--') return false;	/* a real property: it paints something */
		if (props.has(p)) return false;			/* a custom property the theme itself reads */
	}
	return true;
}

/* ---- the verdict is a property of the sheet, taken BEFORE we rewrite it ----
 *
 * An invasive verdict is sticky, and must be: once rehostIntoThemeLayer() fences a sheet, what
 * stands in the DOM is no longer the CSS its app wrote, and re-judging our own edit answers a
 * different question.
 *
 * Only `true` is kept. A clean sheet can still grow hostile rules — an app building its CSS with
 * insertRule() has an empty sheet the first time we look — so a clean verdict stays provisional. */
const _invasive = new WeakSet();

function invasiveSheet(el, universe) {
	if (_invasive.has(el)) return true;
	const v = judgeSheet(el, universe);
	/* Cache only a verdict we could actually read. A <link> has no .sheet until its bytes land, so
	 * judgeSheet's "unreadable -> invasive" default fires for every linked app stylesheet the
	 * instant the observer sees it; remembering that turns a benign sheet into a permanently spent
	 * document and every navigation from that page into a full load.
	 *
	 * Re-taking is safe and does not break the rule above, because the cases do not overlap: a
	 * <style> in the document always has a sheet, so its verdict is cached when taken, and a <link>
	 * is never edited (the element is disabled and the href re-imported), so a later read still sees
	 * the app's untouched CSS.
	 *
	 * The conservative half is preserved: `v` is returned as taken, so an unreadable sheet is still
	 * fenced on sight. Only the memory is dropped. */
	let readable;
	try { readable = !!el.sheet; } catch (e) { readable = false; }
	if (v && readable) _invasive.add(el);
	return v;
}

/* true when this sheet can repaint a page that is not its own. A sheet that is not readable —
 * still loading, 404, cross-origin — is invasive by default: unknown CSS takes the slow path,
 * never the broken one. */
function judgeSheet(el, universe) {
	let sheet;
	try { sheet = el.sheet; } catch (e) { return true; }
	if (!sheet) return true;

	const { names, props } = universe;
	let invasive = false;
	const walk = (rules) => {
		for (const r of rules) {
			if (invasive) return;
			if (r.selectorText) {
				/* One question, the same one fenceRules() asks: is this part pinned inside the
				 * app's own markup? A part with no such pin matches the same widgets everywhere,
				 * and that is what invasive means.
				 *
				 * Do not ask "does it name anything the theme styles?" first: a pin is a name the
				 * theme does NOT know, so "names nothing of ours" reads as pinned when it often
				 * means the opposite — `*:not(#zzz)` and `[class]` are both unpinned and both match
				 * the whole document, and the sheet was judged clean while flattening 95 of 338
				 * chrome elements. */
				for (const p of selectorParts(r.selectorText)) {
					if (pinnedToApp(p, names)) continue;
					/* Unpinned, but it may still be unable to touch us: a rule whose every
					 * declaration is a custom property this theme never reads is inert wherever it
					 * lands (`:root { --app-temp-status-temp: … }`). */
					if (inertDeclarations(r, props)) continue;
					invasive = true;
					return;
				}
			}
			if (r.cssRules) walk(r.cssRules);
			/* an @import's rules are not r.cssRules — follow it, or the verdict is blind to every
			 * rule behind it, including our own re-hosting shim. An unreadable import is invasive,
			 * like any sheet we cannot read. */
			if (r.styleSheet) {
				let imported;
				try { imported = r.styleSheet.cssRules; } catch (e) { invasive = true; return; }
				if (imported) walk(imported);
			}
		}
	};
	try { walk(sheet.cssRules); } catch (e) { return true; }
	return invasive;
}

/* Both element kinds count: `luci-app-banip` and `luci-app-adblock` append a <link> to <head> at
 * module eval styling stock widgets, unlayered, on every page. A <link> inside the view tree needs
 * no handling — it dies with the swap. */
const VIEW_SHEETS = 'style:not([data-fs-shell]), link[rel~="stylesheet"]:not([data-fs-shell])';

/* Does this sheet outlive the page it arrived with? Everything this module decides hangs off that,
 * and the answer is where the element sits: a sheet inside the view tree dies with the swap, so it
 * can neither poison the next page, nor need scoping, nor be a duplicate worth removing. Named
 * because `!el.closest('#view')` states where an element is, not what follows from it. */
function outlivesPage(el) {
	return !el.closest('#view');
}

/* Is `path` — a menu.d node's `css` — already carried by this document in a form that SURVIVES a
 * swap? The router asks before committing a client navigation, since only a server render emits
 * that <link> and a page whose stylesheet is missing must arrive by full load (fs-router.js). A
 * link inside #view does not count: it is about to be deleted with the view.
 *
 * Whole path, never a suffix. head.ut prints `{{ resource }}/{{ dispatched.css }}?v=…` from the
 * same base value the runtime holds, so the server's href is reconstructable rather than guessable
 * — only the cache key comes off. A suffix match would let a third-party node declaring
 * `"css": "custom.css"` read as already-carried on the strength of `luci-app-adblock`'s own
 * custom.css, and the router would swap into a page whose stylesheet was never linked.
 *
 * Equality also fails in the direction that only costs speed: L.path() drops a path containing
 * characters it does not allow (no `+ ~ ( ) @ ! ' $ &`, nothing non-ASCII), while head.ut
 * interpolates `css` raw, so such a node's `want` collapses to the bare base, matches nothing, and
 * every entry into that page is a full load. No in-tree node sets `css`, so the shape is
 * third-party only. */
function documentCarries(path) {
	const want = L.resource(String(path));
	for (const link of document.querySelectorAll('link[rel~="stylesheet"][href]')) {
		if (!outlivesPage(link))
			continue;
		if ((link.getAttribute('href') || '').split('?')[0] === want)
			return true;
	}
	return false;
}

/* An invasive sheet we OWN is contained: scopeToCurrentPage() darkens it the moment the router
 * stamps the new page. One we could not attribute (an @import at the top, a sheet built with
 * insertRule(), anything unreadable) still spends the document, which is the conservative half.
 *
 * A SILENCED sheet is contained too. Re-hosting a <link> owns the @import shim and silences the
 * original for good, but the original stays in the document and a disabled sheet still answers
 * `cssRules` — counting it would re-judge it invasive on every ask, owned by nobody, and turn the
 * SPA router off for the life of the document on exactly the apps this module was written for.
 *
 * Sound because `el.sheet.disabled = true` is what decides whether CSS paints (see silence()) and
 * nothing re-enables it: scopeToCurrentPage() only touches sheets in `_owner`, and the silenced
 * original is deliberately not one. */
function documentPoisoned() {
	const names = themeNames();
	return Array.prototype.some.call(
		document.querySelectorAll(VIEW_SHEETS),
		(el) => outlivesPage(el)
			&& (!names || (invasiveSheet(el, names) && !_owner.has(el) && !_silenced.has(el))));
}

/* ---- an invasive sheet still has to render ITS page: re-host it into the theme layer ----
 *
 * documentPoisoned() saves every page after this one; it cannot save this one, where the sheet is
 * already applying. Every footstrap rule lives in a @layer and an unlayered normal declaration
 * beats a layered one at any specificity, so a third-party `* { margin: 0; padding: 0 }` owns the
 * chrome outright — on a layer-less theme the same `*` (0,0,0) loses to any class selector. The
 * layers are what hand it the win (issue #8).
 *
 * So put the sheet back on specificity footing by re-hosting it into the EXISTING `theme` layer;
 * only same-layer arbitrates by specificity. Measured on the real cascade:
 *
 *   app unlayered (today)        chrome BROKEN   app's own design OK
 *   app -> @layer theme          chrome OK       app's own design OK
 *   app -> @layer before theme   chrome OK       app's own design BROKEN
 *
 * Do not take the third shape: the theme would then beat the app at any specificity, including the
 * rules the app aims at its own page (openclash's `#tab-header ul.cbi-tabmenu li` against our
 * `ul.cbi-tabmenu li`). In `theme` the app keeps them and `*` still loses to the chrome's 0,3,1. No
 * new layer is declared — a re-opened `@layer theme` block appends to the one 00-header.css names.
 *
 * This deliberately does not fix `base`: the app must outrank `theme` for its own page to work, so
 * it sits above `base` and `*` still wipes base's widget padding. An unlayered `*` beats base
 * today too, and the only way out would re-break the app.
 *
 * Never delete the sheet instead. Re-hosting moves where a rule lands in the cascade and every rule
 * still exists, so a library's "did I already import this?" check still finds its sheet; a <link>
 * is disabled rather than removed, so an app that looks its own <link> up by href still finds it. */
/* ---- the fence: the chrome is ours, so make a foreign rule unable to MATCH it ----
 *
 * Re-hosting settles a fight on specificity but not one against `!important`, which ranks above
 * layers. The only pure-CSS answer to a foreign flag is our own flag in an earlier layer — ~550 of
 * them, and the `color`/`background` ones would beat this theme's own forced-colors block.
 *
 * So do not out-rank the rule: put the chrome where it cannot be addressed. Appending
 * `:where(:not([data-fs-chrome], [data-fs-chrome] *))` to a foreign selector's subject leaves it
 * matching everything except us, and `!important` has nothing left to win.
 *
 * The chrome is not one element, so the fence names no element: naming `.fs-sidebar` left the skip
 * link and the search overlay exposed while every test said the chrome was defended. With
 * `data-fs-chrome` an element declares that it is ours where it is written, and the fence and the
 * pin follow. `npm run chrome-fence` holds the three together.
 *
 * `:where()` is load-bearing: it contributes zero specificity, so the app's rules keep their exact
 * weight against each other and against the theme on its own page. A plain `:not(.fs-sidebar)`
 * takes its argument's specificity and would re-order the app's stylesheet against itself.
 *
 * Only unpinned parts are fenced: a pinned part cannot reach the chrome anyway.
 *
 * Two silent traps, both of which cost the app its rule:
 *  - a selector LIST must be fenced part by part; appending to the whole `selectorText` fences only
 *    the last part, leaving `*, ul` as `*, ul:where(…)`;
 *  - a pseudo-element must stay last — `a::after` plus a tail append serialises to
 *    `a::after:where()`, the argument silently eaten, matching nothing, and the setter reports
 *    success. The fence goes before it: `a:where(…)::after`. */
const CHROME_FENCE = ':where(:not([data-fs-chrome],[data-fs-chrome] *))';

function fenceSelector(part) {
	/* The getter always normalises a pseudo-element to `::`, legacy `:before` included. The split
	 * point is found on the mask and sliced out of the original, because `::` is legal inside a
	 * quoted attribute value (an IPv6 literal): read raw, the fence lands inside the quotes and the
	 * app's rule comes back matching a value it never wrote, as valid CSS the setter accepts. */
	const i = maskStrings(part).indexOf('::');
	return i < 0 ? part + CHROME_FENCE : part.slice(0, i) + CHROME_FENCE + part.slice(i);
}

function fenceRules(rules, names) {
	for (const r of rules) {
		if (r.selectorText) {
			const parts = selectorParts(r.selectorText);
			if (parts.length && parts.some((p) => !pinnedToApp(p, names))) {
				/* the setter parses the whole selector and, on one it cannot parse, does nothing
				 * and does not throw, so a failure leaves the rule unfenced rather than
				 * half-written */
				try {
					r.selectorText = parts
						.map((p) => (pinnedToApp(p, names) ? p : fenceSelector(p))).join(', ');
				} catch (e) { /* left unfenced on purpose: the app keeps its rule */ }
			}
		}
		if (r.cssRules) fenceRules(r.cssRules, names);
	}
}

/* An @import's rules live in a separately fetched sheet, so they are not there when the shim is
 * inserted: retry until they are, then fence. A sheet that never becomes readable stays unfenced,
 * which is where we already were.
 *
 * The bound is a deadline in ms, not a frame count — 60 frames is 1 s at 60 Hz and 2 s on a 30 Hz
 * panel, so the budget would move with the display. Frames remain the retry tick, since a cache hit
 * lands on the first one. */
function fenceImported(styleEl, names, until) {
	/* no initialiser: every path below assigns, so `= null` would be a dead store
	 * (no-useless-assignment) */
	let rules;
	try {
		const first = styleEl.sheet && styleEl.sheet.cssRules[0];
		rules = first && first.styleSheet && first.styleSheet.cssRules;
	} catch (e) { rules = null; }
	if (rules) { fenceRules(rules, names); return; }
	if (Date.now() < until) { requestAnimationFrame(() => fenceImported(styleEl, names, until)); return; }
	/* Giving up is reportable: the irreversible half already happened — the app's original is
	 * silenced and the shim owns the page — so a fence that never lands leaves the app's rules
	 * reaching the chrome while every later pass skips the sheet as handled. rAF does not fire in a
	 * background tab, so a page opened in one can pass the deadline without a single retry. */
	console.error('footstrap: could not read the re-hosted @import within the deadline — the sheet '
		+ 'stays unfenced and may repaint the chrome on this page.', styleEl);
}

/* what a sheet IS, as text: the rules that are applying, not the markup that may have produced
 * them. Serialised only to compare, never re-parsed. */
const serializeRules = (rules) => Array.prototype.map.call(rules, (r) => r.cssText).join('\n');

/* ---- a <style>'s textContent is NOT its sheet ----
 *
 * Wrapping re-sets textContent, which re-parses, and whatever the parse does not reproduce is
 * deleted — by the one fix in this file whose thesis is that deleting a view's CSS is one-way. Two
 * shapes where the text does not describe the sheet: a <style> filled by insertRule() (the text is
 * empty while the rules apply, so the wrap writes `@layer theme {}` over a live sheet), and one
 * carrying @import (invalid inside @layer, so the wrapped copy comes back without it).
 *
 * So ask the exact question — does re-parsing this text give back the sheet that is applying? —
 * rather than enumerate the shapes, which is what missed insertRule(). The probe is a constructible
 * sheet: never adopted, so nothing paints and no observer sees it. It also drops @import per spec,
 * so that case needs no test of its own.
 *
 * No probe means no answer, and the honest answer to "may I re-parse this?" is then no: the sheet
 * keeps every rule and the fence still holds the chrome without the wrap. */
let _probe = null;
function textIsSheet(el, live) {
	try {
		if (!_probe) _probe = new CSSStyleSheet();
		_probe.replaceSync(el.textContent);
		return serializeRules(_probe.cssRules) === serializeRules(live);
	} catch (e) { return false; }
}

/* Sheets taken out of the cascade for good — the re-hosted <link> originals. Remembered because a
 * silenced sheet is still an element that answers `cssRules`, so every later ask re-judges it
 * invasive; see documentPoisoned(). */
const _silenced = new WeakSet();

/* Take a re-hosted <link> out of the cascade, and mean it.
 *
 * `el.disabled = true` alone does not: the IDL attribute forwards to the ELEMENT's flag, while
 * `el.sheet.disabled` is what decides whether the CSS paints — and a <link> that is still loading
 * has no `.sheet` for the assignment to reach. Every runtime injection is in that state when the
 * observer hands it here, so the sheet comes up enabled when the bytes arrive and the app's
 * original, unfenced CSS paints beside the fenced shim (95 of 338 chrome elements flattened). */
function silence(el) {
	_silenced.add(el);
	setEnabled(el, false);
}

/* ---- page ownership: contain an invasive sheet instead of spending the document ----
 *
 * A foreign sheet is injected by ONE page and has no business painting any other. Treating every
 * invasive sheet as spending the document is correct but is paid by ordinary pages: stock
 * `luci-app-filemanager` lands two <style>s in <head>, both invasive on their bare selectors, and
 * `luci-app-ssclash` adds four more as the Ace editor initialises. With ownership, leaving either
 * page stays an in-place SPA navigation (medians 24 ms and 27 ms) instead of a full load.
 *
 * Removing the sheet on the way out is the obvious fix and the wrong one: an append at module top
 * level happens once, because `L.require` caches the module, so a second visit renders unstyled.
 * Disabling is reversible, which is the whole difference.
 *
 * Owner = the page in `body[data-page]` when the sheet was re-hosted. fs-router's navigate() stamps
 * data-page BEFORE it require()s the view class, so when a view module evaluates and appends its
 * <style> the attribute already names its page; on a full load the server stamped it.
 *
 * Recorded on the element that PAINTS, never on the permanently silenced one: for a <link> that is
 * the @import shim, and re-enabling the original would undo silence() and put the app's unfenced
 * CSS back over the chrome.
 *
 * The owner is the APP, not the page: an app whose pages share one injected <style> would have it
 * owned by whichever page loaded first and arrive dark on the others. `admin/<group>/<app>` — the
 * first three dispatch segments — is the smallest key that keeps an app's own pages together while
 * still blocking the leak onto other apps and onto stock pages.
 *
 * Segments, never the dash-joined `data-page`: a dispatch segment may contain a dash
 * (`admin/system/package-manager`), so splitting the attribute on '-' would cut inside a name. */
const _owner = new WeakMap();
const APP_DEPTH = 3;

/* the router hands this over on every navigation; until it does — the initial full load — ask the
 * server which page it dispatched to */
let _curKey = null;

function appKey(segs) {
	return (segs || []).slice(0, APP_DEPTH).join('/');
}

/* ---- the URL is not the page ----
 *
 * `L.env.dispatchpath` is the leaf the SERVER resolved this request to, while the address bar holds
 * what was asked for: LuCI's dispatcher walks a node down to its firstchild without rewriting the
 * URL, so `/cgi-bin/luci/admin/status` and `/cgi-bin/luci/` both dispatch to
 * `admin/status/overview`. Keyed on the URL, the first SPA navigation away disables the sheets that
 * page owns — and the app's original <link> is already silenced for good, so nothing paints them
 * again for the life of the document. */
/* Who a sheet injected right now belongs to, when that is not the page the chrome is showing.
 *
 * A require in flight cannot be stopped: click a page whose module injects CSS, click away before
 * it lands, and the <style> appears after the router has stamped data-page for the page that
 * superseded it. Credited to currentKey() it is bound to the wrong page for the life of the
 * document — disabled on its own page and enabled on one it has no business painting. So the router
 * names the owner for the duration of such a require (fs-router.js), and this is that hint.
 *
 * One slot, stamped with the navigation that set it. The router only names an owner for a require
 * that has yet to evaluate its module. Two cold requires can still overlap, and then the newer one
 * wins the slot — it is the page the user is looking at, and crediting its sheet to the superseded
 * page would leave the visible page unpainted. The generation stamp keeps the older require's
 * `.finally` from clearing a slot the newer one now holds.
 *
 * Nothing better is available: LuCI evaluates a view module inside `eval()` in its own require(),
 * so nothing observable says which module is running when a <style> appears. The remaining hole is
 * two cold requires overlapping. */
let _ownerHint = null;
let _ownerGen = -1;
function attributeTo(segs, gen) {
	/* a stale require letting go of a slot somebody else now holds: leave it alone */
	if (segs == null && gen !== _ownerGen) return;
	_ownerHint = (segs == null) ? null : appKey(segs);
	_ownerGen = (segs == null) ? -1 : gen;
}

function ownerKey() {
	return (_ownerHint !== null) ? _ownerHint : currentKey();
}

function currentKey() {
	if (_curKey !== null) return _curKey;
	const dp = L.env && L.env.dispatchpath;
	if (dp && dp.length) return appKey(dp);
	/* no env to read (a document that never got the bootstrap): the URL is all there is */
	const p = location.pathname.replace(/^.*\/cgi-bin\/luci\/?/, '').replace(/\/+$/, '');
	return appKey(p ? p.split('/') : []);
}

/* Both halves, for the reason silence() gives: el.disabled is the element's flag, el.sheet.disabled
 * decides whether the CSS paints, and a still-loading <link> has no .sheet for the assignment to
 * reach — so a switch-off re-asserts once the bytes arrive. `once`, because the element is marked
 * fsLayered by then and a sheet that never loads has nothing to silence. */
function setEnabled(el, on) {
	el.disabled = !on;
	if (el.sheet) el.sheet.disabled = !on;
	else if (!on) el.addEventListener('load', () => { if (el.sheet) el.sheet.disabled = true; }, { once: true });
}

/* Called by the router right after it stamps data-page, with the RESOLVED segments. Only sheets we
 * own are touched: a clean sheet is harmless, and an invasive one we could not attribute still
 * poisons the document and keeps the full-load path rather than being disabled on its own page.
 *
 * `keep` is the page still ON SCREEN. A client navigation has a window in which two pages are real
 * — the incoming one renders into a hidden stage while the outgoing one is still being read — and
 * scoping is needed before that staged render, since a view must not measure itself through a sheet
 * that does not own its page. Doing both halves then would strip the outgoing page's stylesheet off
 * content the user is looking at, so the router calls this twice: with `keep` before the staged
 * render, and with nothing at the swap. */
function scopeToCurrentPage(segs, keep) {
	if (segs) _curKey = appKey(segs);
	const key = currentKey();
	const spared = (keep && keep.length) ? appKey(keep) : null;
	document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
		if (!outlivesPage(el) || !_owner.has(el)) return;
		const owner = _owner.get(el);
		if (spared !== null && owner === spared && owner !== key) return;
		setEnabled(el, owner === key);
	});
}

/* Take a sheet and scope it in the same breath. scopeToCurrentPage() runs on NAVIGATION, so it
 * only sees sheets that were already here; one arriving afterwards is scoped by nobody until the
 * next click — which is exactly the cold-require case the owner hint exists for, where the sheet
 * paints a page it does not belong to in the meantime.
 *
 * `ownerKey()` is the page the sheet belongs to and `currentKey()` the page on screen; on every
 * ordinary arrival they are the same string and this is a no-op. */
function claimOwner(el) {
	const key = ownerKey();
	_owner.set(el, key);
	if (outlivesPage(el)) setEnabled(el, key === currentKey());
}

function rehostIntoThemeLayer(el, universe) {
	if (el.dataset.fsLayered) return;

	if (el.tagName === 'LINK') {
		/* a <link>'s rules cannot be moved into a layer in place, but an @import can name one; the
		 * href is absolute and same-origin, so the re-fetch is a cache hit */
		const s = document.createElement('style');
		s.dataset.fsLayered = '1';
		s.textContent = '@import url("' + el.href.replace(/["\\]/g, '\\$&') + '") layer(theme);';
		el.dataset.fsLayered = '1';
		el.after(s);		/* keep source order: ties inside the layer still resolve as they did */
		silence(el);
		claimOwner(s);
		fenceImported(s, universe.names, Date.now() + 1000);	/* a cache hit lands on the first frame */
		return;
	}

	let rules;
	try { rules = el.sheet && el.sheet.cssRules; } catch (e) { return; }
	/* No rules yet: nothing to re-host, nothing to fence and nothing to MARK. An app that appends an
	 * empty <style> and fills it with insertRule() arrives here first, and marking it handled would
	 * leave the sheet it is about to build unfenced for the life of the document. */
	if (!rules || !rules.length) return;

	/* Handled, and never twice: fenceRules() is not idempotent, because pinnedToApp() strips a
	 * functional pseudo-class before looking for the app's name, so an already-fenced selector reads
	 * as unpinned again and a second pass appends a second fence. */
	el.dataset.fsLayered = '1';

	/* Wrap only if the text still IS the sheet (textIsSheet). When it is not, the sheet stays
	 * unlayered rather than lose rules, and is still fenced below — the fence is pure CSSOM, needs
	 * no re-parse, and is the half that answers `!important` anyway.
	 *
	 * Layer by text, fence by CSSOM, in that order: re-setting textContent re-parses and would throw
	 * away any selector already rewritten. A <style>'s url()s resolve against the document either
	 * way, which is not true of a <link> — cssText serialises `url("img.png")` still relative, so
	 * inlining a linked sheet would re-base every image and font in it. */
	if (textIsSheet(el, rules)) {
		origText.set(el, el.textContent);	/* dedupeViewSheets keys on this — see there */
		el.textContent = '@layer theme {\n' + el.textContent + '\n}';
	}
	try { if (el.sheet) fenceRules(el.sheet.cssRules, universe.names); } catch (e) { /* unfenced, not broken */ }
	/* Last, because the line above may have re-parsed the sheet: assigning textContent throws the
	 * old CSSStyleSheet away and the new one comes back ENABLED, so claiming before the wrap
	 * switches a sheet off and back on within the same call. ownerKey() is read here either way,
	 * while the router's hint still names the page whose module is evaluating. */
	claimOwner(el);	/* a <style> is re-hosted IN PLACE, so it paints itself */
}

/* Re-hosting needs the theme's own selectors to tell an invasive sheet from an inert one, so if
 * cascade.css cannot be read, re-host nothing: documentPoisoned() already fails every sheet to the
 * slow path, and demoting an app we could not judge is the one move with no way back. */
function rehostInvasiveSheets() {
	const universe = themeNames();		/* {names, props} — NOT a bare Set; the fence wants .names */
	if (!universe) return;
	document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
		if (el.dataset.fsLayered) return;
		/* A sheet with no rules yet is not an innocent sheet: `insertRule()` produces no mutation
		 * record, so an app that appends an empty <style> and fills it later is judged empty here
		 * and never looked at again — measured, a `* { padding: 0 !important }` built that way
		 * flattened the chrome and stayed unfenced for the life of the document. One deferred look
		 * per such element is enough, and costs nothing where a <style> arrives with its rules
		 * already in it. */
		let rules = null;
		try { rules = el.sheet && el.sheet.cssRules; } catch (e) { /* cross-origin: judged below */ }
		if (rules && !rules.length && !el.__fsRecheck) {
			el.__fsRecheck = true;
			window.setTimeout(() => {
				let now;
				try { now = el.isConnected && el.sheet && el.sheet.cssRules; } catch (e) { return; }
				if (now && now.length) rehostInvasiveSheets();
			}, 2000);
		}
		if (invasiveSheet(el, universe)) rehostIntoThemeLayer(el, universe);
	});
}

/* ---- the one thing that IS safe to remove: a byte-identical second copy ----
 *
 * Not deleting view CSS costs where an app injects on every render: `luci-app-podkop` injects 4 KB
 * from render() with no guard, `luci-app-mosdns` re-appends three CodeMirror <link>s, so every SPA
 * re-visit adds a copy that never stops being parsed. Dropping an exact duplicate cannot break
 * anyone, because the rules do not go away — the surviving copy is byte-identical, so a library's
 * "have I already imported this?" check still finds its sheet. Keep the FIRST copy: it is what any
 * handle the app kept points at.
 *
 * Key a <style> on what its APP wrote, not on what stands in the DOM: re-hosting rewrites the text,
 * so a wrapped first copy and the app's next identical injection would stop matching and the copies
 * would pile up again. A <link> keys on href, which re-hosting leaves alone.
 *
 * Re-hosting must therefore run BEFORE this: a re-hosted <link>'s shim is appended to <head>, which
 * is earlier in document order than a template's <link> in .fs-content, so "keep the first" would
 * keep the raw copy and strand the shim. Re-host first and both copies are equivalent by the time
 * they are compared. */
function sheetKey(el) {
	if (el.tagName === 'LINK') return 'LINK|' + el.href;
	const t = origText.get(el);
	if (t !== undefined) return 'STYLE|' + t;
	/* Not wrapped, so no original was kept — and the textContent may not BE the sheet (textIsSheet):
	 * every insertRule-built <style> has an empty one, so keying on the text gives them all the same
	 * key and removes the second as a "duplicate" of a sheet it shares nothing with. Key on what is
	 * applying instead. */
	let rules;
	try { rules = el.sheet && el.sheet.cssRules; } catch (e) { return null; }
	/* a sheet with no rules is a duplicate of nothing, and is very likely a <style> an app has
	 * appended but not yet filled: removing it strands the handle it will insertRule through */
	if (!rules || !rules.length) return null;
	return 'STYLE|' + serializeRules(rules);
}

function dedupeViewSheets() {
	const seen = new Set();
	document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
		if (!outlivesPage(el)) return;
		const key = sheetKey(el);
		if (key === null) return;
		if (seen.has(key)) el.remove();
		else seen.add(key);
	});
}

/* ---- the layer order is a document-wide fact, and a sheet inserted first can rewrite it ----
 *
 * `@layer tokens, base, theme, page;` in 00-header.css is what makes theme beat base, and it holds
 * only while cascade.css is the FIRST sheet in the document to name a layer: the order is fixed by
 * first appearance, so an earlier sheet naming `theme` makes theme the weakest layer and inverts
 * the whole cascade — base's `* { padding: 0 }` then wins over the chrome's own rules.
 *
 * Re-hosting an app's sheet into `@layer theme` can cause exactly that, because where the app put
 * its <style> is the app's choice: Ace inserts its <style> as the FIRST CHILD of <head>, ahead of
 * cascade.css, and adds more of them on first hover.
 *
 * The repair is one declaration, and it works because inserting a NEW sheet re-runs the ordering
 * while moving an existing one does not (measured both ways): re-declare the canonical order from
 * a fresh <style> placed first in <head>. Cheap and idempotent, and the one other place that can
 * see the whole document, which is why the order is repeated here rather than derived. */
const LAYER_ORDER = '@layer tokens, base, theme, page;';
let _layerStmt = null;

function reassertLayerOrder() {
	const head = document.head;
	if (!head) return;
	/* the anchor is whichever of ours comes first — cascade.css, or the statement a previous pass
	 * put in front of it — since only a sheet ahead of that can have named a layer before we did */
	const own = [...document.querySelectorAll('link[rel~="stylesheet"]')]
		.find((l) => (/\/cascade\.css/).test(l.href || ''));
	if (!own) return;
	const anchor = _layerStmt && _layerStmt.isConnected ? _layerStmt : own;
	const ahead = [...document.querySelectorAll('style, link[rel~="stylesheet"]')]
		.some((el) => el !== anchor && el !== own &&
			(anchor.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING));
	if (!ahead) return;

	/* a fresh element every time: re-inserting the same node is a move, and a move does not re-run
	 * the ordering. Dropping the previous one keeps this at one spare <style> per document. */
	if (_layerStmt) _layerStmt.remove();
	_layerStmt = document.createElement('style');
	_layerStmt.textContent = LAYER_ORDER;
	head.insertBefore(_layerStmt, head.firstChild);
}

/* Watch <head> rather than deduping on navigation: an app injecting from render() resolves after
 * the router's require() callback, so a nav-time sweep leaves the document permanently carrying one
 * stale duplicate. The observer collapses the copy in the microtask it appears in, and cannot loop
 * — a removal produces a mutation with no added nodes and the handler bails.
 *
 * The immediate pass cannot be the observer's job: a legacy Lua page's <link> is in the SERVER's
 * HTML, parsed and applying before this module is fetched, so there is no mutation to observe. It
 * is re-hosted on the first pass instead, at the cost of a brief flash of unstyled chrome.
 *
 * <head>, deliberately not the whole document: LuCI's poll rewrites content on every tick and this
 * would fire on every tick. */
function watchViewSheets() {
	/* Dedupe on the immediate pass too, in the observer's order (re-host strictly first): a page
	 * whose server HTML prints the same <link> twice produces no mutation for the observer to see,
	 * so without this it carries both links and both @import shims for the life of the document —
	 * 117 KB of CSS parsed twice on luci-app-openclash's Overwrite Settings page. */
	rehostInvasiveSheets();
	dedupeViewSheets();
	reassertLayerOrder();	/* strictly after the re-host: it is the wrap that can invert the order */
	const mo = new MutationObserver((muts) => {
		for (const m of muts)
			for (const n of m.addedNodes)
				if (n.nodeName === 'STYLE' || n.nodeName === 'LINK') {
					/* `continue`, not `return`: our own statement can share a batch with the sheet
					 * that made it necessary */
					if (n === _layerStmt) continue;
					rehostInvasiveSheets();	/* strictly before the dedupe — see there */
					dedupeViewSheets();
					reassertLayerOrder();
					return;
				}
	});
	mo.observe(document.head, { childList: true });
	/* …and <body>, because `document.head.appendChild` is a convention, not a rule: a <style>
	 * appended to <body> after chrome init is seen by nothing otherwise — the immediate pass has
	 * run and the mutation is not observed — and the page the user is ON stays broken.
	 *
	 * childList without subtree, as for <head>: this fires only for direct children of <body>, while
	 * LuCI's poll rewrites content inside #view, a descendant. */
	mo.observe(document.body, { childList: true });
}

return baseclass.extend({
	attributeTo,
	documentCarries,
	documentPoisoned,
	scopeToCurrentPage,
	watchViewSheets
});