perfectionist-plugins 2.4.7 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,8 +4,10 @@ const NAV_ID = "perfectionist-plugins-nav";
4
4
  const PANEL_ID = "perfectionist-plugins-panel";
5
5
  const STYLE_ID = "perfectionist-plugins-style";
6
6
  const TAB_TITLE = "Perfectionism";
7
+ const GROUP_TITLE = "Plugins";
8
+ const OPEN_CLASS = "perf-plugins-open";
7
9
 
8
- export const version = "2.4.6";
10
+ export const version = "2.5.0";
9
11
 
10
12
  const PKG = "perfectionist-plugins";
11
13
  const CDN = "https://cdn.jsdelivr.net/npm";
@@ -23,143 +25,143 @@ const BUNDLED_MODULES = [
23
25
  id: "better-client",
24
26
  file: "better-client.js",
25
27
  name: "BetterClient",
26
- version: "1.7.0",
27
- description: "Makes the client lighter and smoother without changing how it looks. Cuts tracking and repeated requests and fixes small bugs. Has an optional Performance mode for slower PCs.",
28
+ version: "2.0.0",
29
+ description:
30
+ "Makes the client lighter without changing how it looks: stops videos and animations nobody can see, blocks tracking, and pauses the client while you play. Performance mode helps weaker PCs.",
28
31
  },
29
32
  {
30
33
  id: "ban-blocker",
31
34
  file: "ban-blocker.js",
32
35
  name: "Ban Blocker",
33
- version: "3.6.2",
34
- description: "Adds a BLOCK button to your teammates in champ select. While a teammate is blocked, the champion they are about to ban is prepicked for you, so their ban can never land on it.",
36
+ version: "4.0.0",
37
+ description:
38
+ "Adds a BLOCK button to your teammates in champ select. While a teammate is blocked, the champion they hover to ban is instantly declared as your pick, so their ban can never land on it.",
35
39
  },
36
40
  {
37
41
  id: "unlock-profile-skins",
38
42
  file: "unlock-profile-skins.js",
39
43
  name: "Unlock Profile Skins",
40
- version: "1.2.4",
41
- description: "Shows every skin as unlocked in the Set profile background window, event skins included, so you can pick any skin as your profile background.",
44
+ version: "2.0.0",
45
+ description:
46
+ "Shows every skin as unlocked in the Set profile background window, event skins included, so you can pick any skin as your profile background.",
42
47
  },
43
48
  {
44
49
  id: "hide-unowned-skins",
45
50
  file: "hide-unowned-skins.js",
46
51
  name: "Hide Unowned Skins",
47
- version: "2.4.3",
52
+ version: "3.0.0",
48
53
  description: "In champ select, the skin carousel only shows the skins you own for the champion you picked.",
49
54
  },
50
55
  {
51
56
  id: "auto-accept",
52
57
  file: "auto-accept.js",
53
58
  name: "Auto Accept",
54
- version: "1.1.3",
59
+ version: "1.2.0",
55
60
  description: "Accepts the match for you when the ready check pops up.",
56
61
  },
57
62
  {
58
63
  id: "dodge-helper",
59
64
  file: "dodge-helper.js",
60
65
  name: "Dodge Helper",
61
- version: "1.4.3",
62
- description: "Adds a DODGE/QUIT button to champ select, showing the LP and queue penalty for the next dodge.",
66
+ version: "2.0.0",
67
+ description:
68
+ "Adds a DODGE button to champ select (QUIT outside ranked) that leaves cleanly, showing the LP and queue lockout your next dodge will cost.",
63
69
  },
64
70
  {
65
71
  id: "auto-claimer",
66
72
  file: "auto-claimer.js",
67
73
  name: "Auto Claimer",
68
- version: "1.2.0",
69
- description: "Claims rewards for you as soon as they are ready: event tracks, missions and the TFT Battle Pass. Optional extras can also buy 1 BE shop items and open mystery loot and chests.",
74
+ version: "1.3.0",
75
+ description:
76
+ "Claims rewards for you as soon as they are ready: event tracks, missions and the TFT Battle Pass. Optional extras can also buy 1 BE shop items and open mystery loot and chests.",
70
77
  },
71
78
  {
72
79
  id: "auto-read",
73
80
  file: "auto-read.js",
74
81
  name: "Auto Read",
75
- version: "1.2.0",
82
+ version: "1.3.0",
76
83
  description: "Marks quests, loot, Clash, TFT and client notices as read for you, so no badge or dot stays lit.",
77
84
  },
78
85
  ];
79
86
 
87
+ // One box per plugin with its Enabled box and Install/Uninstall button, option groups with a caret, and chips.
88
+ // Colours come from the client's own palette and the inputs are the client's own elements.
80
89
  const STYLE_CSS = `
81
- #${PANEL_ID} { display: flex; flex-direction: column; overflow-x: hidden; padding: 15px 20px; position: relative; width: calc(100% - 40px); box-sizing: content-box; color: #f0e6d2; font-family: "LoL Body", Arial, "Helvetica Neue", Helvetica, sans-serif; }
90
+ #${PANEL_ID} { display: flex; flex-direction: column; overflow-x: hidden; padding: 15px 20px; position: relative;
91
+ width: calc(100% - 40px); box-sizing: content-box; color: #a09b8c; font-family: var(--font-body, "LoL Body", Arial, sans-serif); }
82
92
  #${PANEL_ID} > lol-uikit-scrollable { display: block; flex: 1 1 auto; min-height: 0; }
93
+ #${PANEL_ID} .perf-tools { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; margin-bottom: 14px; }
94
+ #${PANEL_ID} .perf-tools lol-uikit-flat-input { flex: 1 1 auto; min-width: 0; }
95
+ #${PANEL_ID} .perf-tools lol-uikit-flat-button-secondary { flex: 0 0 auto; min-width: 112px; }
96
+ #${PANEL_ID} .perf-notice { flex: 0 0 auto; margin: -6px 0 12px; }
83
97
  #${PANEL_ID} .perf-card { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px;
84
- padding: 16px 18px; margin-bottom: 12px; box-sizing: border-box;
85
- background: #010a13; border: 1px solid #785a28; }
86
- #${PANEL_ID} .perf-card:hover { border-color: #c89b3c; }
87
- #${PANEL_ID} .perf-card--missing { opacity: 0.75; }
98
+ padding: 16px 18px; margin-bottom: 12px; box-sizing: border-box; background: #010a13; border: 1px solid #463714; }
99
+ #${PANEL_ID} .perf-card:hover { border-color: #785a28; }
100
+ #${PANEL_ID} .perf-card--missing .perf-info { opacity: 0.7; }
88
101
  #${PANEL_ID} .perf-info { min-width: 0; }
89
- #${PANEL_ID} .perf-name { font-family: "LoL Display", "Times New Roman", Times, Baskerville, Georgia, serif;
90
- font-size: 15px; font-weight: 700; letter-spacing: 1.05px; text-transform: uppercase; color: #f0e6d2; }
91
- #${PANEL_ID} .perf-version { font-family: "LoL Body", Arial, sans-serif; font-size: 10px; font-weight: 700;
102
+ #${PANEL_ID} .perf-name { font-family: var(--font-display, "LoL Display", serif); font-size: 14px; font-weight: 700;
103
+ line-height: 18px; letter-spacing: 1.05px; text-transform: uppercase; color: #f0e6d2; }
104
+ #${PANEL_ID} .perf-version { font-family: var(--font-body, "LoL Body", Arial, sans-serif); font-size: 10px; font-weight: 700;
92
105
  letter-spacing: 0.25px; text-transform: none; color: #c89b3c; margin-left: 8px; }
93
106
  #${PANEL_ID} .perf-desc { font-size: 12px; letter-spacing: 0.3px; color: #a09b8c; margin-top: 7px; line-height: 1.5; max-width: 560px; }
94
107
  #${PANEL_ID} .perf-actions { display: flex; align-items: center; gap: 12px; flex: 0 0 auto; }
95
- #${PANEL_ID} .perf-toggle { position: relative; display: flex; align-items: center; flex: 0 0 auto; cursor: pointer; }
96
- #${PANEL_ID} .perf-toggle input { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; }
97
- #${PANEL_ID} .perf-box { position: relative; width: 18px; height: 18px; box-sizing: border-box;
98
- border: 1px solid #785a28; background: #0a1428; }
99
- #${PANEL_ID} .perf-box::after { content: ""; position: absolute; inset: 1px; display: none;
100
- background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.2 6.4L4.9 9.1L9.8 3.3' fill='none' stroke='%23c89b3c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 12px 12px no-repeat; }
101
- #${PANEL_ID} .perf-toggle input:checked + .perf-box::after { display: block; }
102
- #${PANEL_ID} .perf-toggle:hover .perf-box { border-color: #c89b3c; }
103
- #${PANEL_ID} .perf-toggle input:focus-visible + .perf-box { outline: 1px solid #c89b3c; outline-offset: 2px; }
104
- .perf-btn { font-family: "LoL Body", Arial, sans-serif; font-size: 11px; font-weight: 700; line-height: 12px;
105
- letter-spacing: 1px; text-transform: uppercase; color: #c89b3c; background: #0a1428;
106
- border: 1px solid #785a28; padding: 8px 16px; cursor: pointer; }
107
- .perf-btn:hover { color: #f0e6d2; border-color: #c89b3c; background: #1e2328; }
108
- .perf-btn:disabled { opacity: 0.5; cursor: default; }
109
- #${PANEL_ID} .perf-uninstall { width: 30px; height: 30px; padding: 0; display: flex; align-items: center; justify-content: center; }
108
+ #${PANEL_ID} .perf-actions lol-uikit-flat-checkbox { margin: 0; }
109
+ #${PANEL_ID} .perf-actions lol-uikit-flat-button-secondary,
110
+ #${PANEL_ID} .perf-actions lol-uikit-flat-button { min-width: 96px; }
111
+ #${PANEL_ID} .perf-uninstall { width: 30px; height: 30px; box-sizing: border-box; padding: 0; display: flex; align-items: center;
112
+ justify-content: center; cursor: pointer; background: #1e2328; border: 1px solid #785a28; }
113
+ #${PANEL_ID} .perf-uninstall:hover { border-color: #c8aa6e; background: #1e2328; }
110
114
  #${PANEL_ID} .perf-uninstall::after { content: ""; width: 11px; height: 11px;
111
- background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 2.5L9.5 9.5M9.5 2.5L2.5 9.5' fill='none' stroke='%23c89b3c' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E") center / 11px 11px no-repeat; }
115
+ background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 2.5L9.5 9.5M9.5 2.5L2.5 9.5' fill='none' stroke='%23cdbe91' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E") center / 11px 11px no-repeat; }
116
+ #${PANEL_ID} .perf-uninstall:hover::after { filter: brightness(1.3); }
112
117
  #${PANEL_ID} .perf-empty { font-size: 12px; color: #a09b8c; }
113
118
  #${PANEL_ID} .perf-opts { margin-top: 11px; display: flex; flex-direction: column; gap: 8px; }
114
- #${PANEL_ID} .perf-opt { display: flex; align-items: center; gap: 9px; cursor: pointer; }
115
- #${PANEL_ID} .perf-opt .perf-opt-label { font-size: 11px; letter-spacing: 0.3px; color: #a09b8c; }
116
- #${PANEL_ID} .perf-opt:hover .perf-opt-label { color: #f0e6d2; }
117
- #${PANEL_ID} .perf-opt-group { margin-top: 2px; }
118
119
  #${PANEL_ID} .perf-opt-head { display: flex; align-items: center; gap: 9px; }
119
120
  #${PANEL_ID} .perf-caret { flex: 0 0 auto; width: 28px; height: 28px; cursor: pointer; display: flex; align-items: center; justify-content: center;
120
121
  border: 1px solid transparent; background: transparent; }
121
- #${PANEL_ID} .perf-caret:hover { border-color: #785a28; background: #0a1428; }
122
- #${PANEL_ID} .perf-caret::after { content: ""; width: 12px; height: 12px; transform-origin: center;
123
- background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath d='M2 1l4 3-4 3z' fill='%23c89b3c'/%3E%3C/svg%3E") center / 12px 12px no-repeat;
124
- transition: transform 0.15s; }
125
- #${PANEL_ID} .perf-caret:hover::after { filter: brightness(1.4); }
122
+ #${PANEL_ID} .perf-caret:hover { border-color: #463714; background: #1e2328; }
123
+ #${PANEL_ID} .perf-caret::after { content: ""; width: 12px; height: 12px; transform-origin: center; transition: transform 0.15s;
124
+ background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath d='M2 1l4 3-4 3z' fill='%23c8aa6e'/%3E%3C/svg%3E") center / 12px 12px no-repeat; }
125
+ #${PANEL_ID} .perf-caret:hover::after { filter: brightness(1.3); }
126
126
  #${PANEL_ID} .perf-caret--open::after { transform: rotate(90deg); }
127
127
  #${PANEL_ID} .perf-opt-children { margin: 8px 0 2px 25px; padding-left: 12px; display: flex; flex-direction: column; gap: 8px;
128
- border-left: 1px solid #785a28; }
129
- #${PANEL_ID} .perf-toggle input:disabled + .perf-box,
130
- #${PANEL_ID} .perf-toggle input:disabled ~ .perf-opt-label { opacity: 0.45; }
131
- #${PANEL_ID} .perf-toggle input:disabled ~ .perf-opt-label { cursor: default; }
128
+ border-left: 1px solid #463714; }
129
+ #${PANEL_ID} lol-uikit-flat-checkbox.perf-disabled { opacity: 0.45; pointer-events: none; }
130
+ #${PANEL_ID} .perf-card--locked .perf-caret { opacity: 0.45; }
132
131
  #${PANEL_ID} .perf-chip-block { margin-top: 11px; }
133
132
  #${PANEL_ID} .perf-chips-label { font-size: 10px; font-weight: 700; letter-spacing: 0.6px; text-transform: uppercase; color: #c89b3c; margin-bottom: 7px; }
134
133
  #${PANEL_ID} .perf-chips { display: flex; flex-wrap: wrap; gap: 6px; }
135
- #${PANEL_ID} .perf-chip { font-family: "LoL Body", Arial, "Helvetica Neue", Helvetica, sans-serif; font-size: 10px; font-weight: 700;
136
- letter-spacing: 0.5px; text-transform: uppercase; color: #a09b8c; background: #0a1428; border: 1px solid #785a28;
134
+ #${PANEL_ID} .perf-chip { font-family: var(--font-body, "LoL Body", Arial, sans-serif); font-size: 10px; font-weight: 700;
135
+ letter-spacing: 0.5px; text-transform: uppercase; color: #5b5a56; background: #010a13; border: 1px solid #1e2328;
137
136
  padding: 6px 11px; cursor: pointer; }
138
- #${PANEL_ID} .perf-chip:hover { color: #f0e6d2; border-color: #c89b3c; }
139
- #${PANEL_ID} .perf-chip--on { color: #010a13; background: #c89b3c; border-color: #c89b3c; }
140
- #${PANEL_ID} .perf-chip--on:hover { background: #f0e6d2; }
141
- #${PANEL_ID} .perf-tools { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; margin-bottom: 14px; }
142
- #${PANEL_ID} .perf-search { flex: 1 1 auto; min-width: 0; box-sizing: border-box;
143
- font-family: "LoL Body", Arial, "Helvetica Neue", Helvetica, sans-serif; font-size: 12px; letter-spacing: 0.3px;
144
- color: #f0e6d2; background: #0a1428; border: 1px solid #785a28; padding: 9px 12px; outline: none; }
145
- #${PANEL_ID} .perf-search:focus { border-color: #c89b3c; }
146
- #${PANEL_ID} .perf-search::placeholder { color: #a09b8c; }
147
- #${PANEL_ID} .perf-master { flex: 0 0 auto; white-space: nowrap; }
148
- #${PANEL_ID} .perf-card--locked .perf-opts { pointer-events: none; }
149
- #${PANEL_ID} .perf-card--locked .perf-actions > .perf-toggle { pointer-events: none; }
150
- #${PANEL_ID} .perf-card--locked .perf-caret { opacity: 0.45; }
151
- #${PANEL_ID} .perf-card--locked .perf-chips-label { opacity: 0.45; }
137
+ #${PANEL_ID} .perf-chip:hover { color: #a09b8c; border-color: #463714; }
138
+ #${PANEL_ID} .perf-chip--on { color: #c8aa6e; background: #0f1418; border-color: #463714; }
139
+ #${PANEL_ID} .perf-chip--on:hover { color: #c8aa6e; border-color: #785a28; }
152
140
  #${PANEL_ID} .perf-chip:disabled { opacity: 0.45; cursor: default; }
153
- #${PANEL_ID} .perf-chip:disabled:hover { color: #a09b8c; border-color: #785a28; background: #0a1428; }
154
- #${PANEL_ID} .perf-chip--on:disabled:hover { color: #010a13; border-color: #c89b3c; background: #c89b3c; }
141
+ #${PANEL_ID} .perf-chip:disabled:hover { color: #5b5a56; border-color: #1e2328; }
142
+ #${PANEL_ID} .perf-chip--on:disabled:hover { color: #c8aa6e; border-color: #463714; }
143
+
144
+ /* While the page is open: hide the client's own options pane and tab highlight, and show our breadcrumb.
145
+ Pure CSS, so nothing the client renders (attributes, text nodes, inline styles) is ever modified. */
146
+ .lol-settings-container.${OPEN_CLASS} .lol-settings-options { display: none !important; }
147
+ .lol-settings-container.${OPEN_CLASS} lol-uikit-navigation-item.lol-settings-nav:not(#${NAV_ID})[active] { color: #c8aa6e !important; cursor: pointer !important; padding-left: var(--perf-nav-pad, 14px) !important; }
148
+ .lol-settings-container.${OPEN_CLASS} lol-uikit-navigation-item.lol-settings-nav:not(#${NAV_ID})[active]:hover { color: #f0e6d2 !important; }
149
+ .lol-settings-container.${OPEN_CLASS} lol-uikit-navigation-item.lol-settings-nav:not(#${NAV_ID})[active]::before { opacity: 0 !important; }
150
+ .lol-settings-container.${OPEN_CLASS} .lol-settings-header-group-name,
151
+ .lol-settings-container.${OPEN_CLASS} .lol-settings-title-current { font-size: 0 !important; letter-spacing: 0 !important; }
152
+ .lol-settings-container.${OPEN_CLASS} .lol-settings-header-group-name::after { content: "${GROUP_TITLE.toUpperCase()}"; font-size: var(--perf-crumb-group-size, 20px); letter-spacing: var(--perf-crumb-group-spacing, normal); }
153
+ .lol-settings-container.${OPEN_CLASS} .lol-settings-title-current::after { content: "${TAB_TITLE.toUpperCase()}"; font-size: var(--perf-crumb-size, 20px); letter-spacing: var(--perf-crumb-spacing, normal); }
155
154
  `;
156
155
 
157
156
  const registry = new Map();
158
157
  const watchers = new Map();
159
158
  const requestCache = new Map();
159
+ const frameHooks = new Set();
160
160
  let socket = null;
161
161
  let observer = null;
162
162
  let scanScheduled = false;
163
+ let pendingNodes = [];
164
+ let fullScan = true;
163
165
 
164
166
  let coords = null;
165
167
  const resolved = new Map();
@@ -176,6 +178,7 @@ let saveBlocked = false;
176
178
 
177
179
  const SAVED_STATE_TRIES = 3;
178
180
  const SAVED_STATE_RETRY_MS = 250;
181
+ const FULL_SCAN_THRESHOLD = 300;
179
182
 
180
183
  function isObject(value) {
181
184
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -215,6 +218,7 @@ function normalizeState(saved) {
215
218
  options: isObject(saved?.options) ? { ...saved.options } : {},
216
219
  masterLocked: saved?.masterLocked === true,
217
220
  };
221
+ delete next.enabled.undefined;
218
222
  for (const mod of BUNDLED_MODULES) {
219
223
  if (mod.core) continue;
220
224
  if (next.installed[mod.id] === undefined && (next.enabled[mod.id] === true || isObject(next.options[mod.id]))) {
@@ -361,7 +365,7 @@ function setOption(id, key, value, refresh = true) {
361
365
  saveState();
362
366
  const entry = registry.get(id);
363
367
  try {
364
- entry?.onOptionChange?.(key, Boolean(value));
368
+ if (entry?.active) entry.onOptionChange?.(key, Boolean(value));
365
369
  } catch (error) {
366
370
  logError(`${entry?.name ?? id} could not apply the option change.`, error);
367
371
  }
@@ -384,6 +388,7 @@ function isInstalled(mod) {
384
388
  }
385
389
 
386
390
  function moduleUrl(file) {
391
+ if (coords?.moduleBase) return new URL(file, coords.moduleBase).href;
387
392
  return `${CDN}/${PKG}@${runningVersion}/${file}`;
388
393
  }
389
394
 
@@ -425,10 +430,9 @@ async function loadModule(mod) {
425
430
  }
426
431
 
427
432
  async function loadEnabledModules() {
428
- for (const mod of BUNDLED_MODULES) {
429
- if (mod.core || !isInstalled(mod) || !isPluginEnabled(mod.id)) continue;
430
- await loadModule(mod);
431
- }
433
+ const wanted = BUNDLED_MODULES.filter((mod) => !mod.core && isInstalled(mod) && isPluginEnabled(mod.id));
434
+ // Fetch every enabled module in parallel; each one registers itself as soon as it arrives.
435
+ await Promise.all(wanted.map((mod) => loadModule(mod)));
432
436
  }
433
437
 
434
438
  async function loadModules() {
@@ -437,6 +441,11 @@ async function loadModules() {
437
441
  await loadEnabledModules();
438
442
  }
439
443
 
444
+ /* ------------------------------------------------------------------------------------------------
445
+ * DOM watching. One shared MutationObserver for every plugin. A frame only inspects the nodes that
446
+ * were added since the last frame, and falls back to a full document query for large re-renders.
447
+ * ---------------------------------------------------------------------------------------------- */
448
+
440
449
  function scheduleScan() {
441
450
  if (scanScheduled) return;
442
451
  scanScheduled = true;
@@ -446,37 +455,76 @@ function scheduleScan() {
446
455
  });
447
456
  }
448
457
 
458
+ function visitMatches(entries, nodes) {
459
+ for (const node of nodes) {
460
+ for (const entry of entries) {
461
+ if (entry.seen.has(node)) continue;
462
+ entry.seen.add(node);
463
+ try {
464
+ entry.callback(node);
465
+ } catch (error) {
466
+ logError(`Watcher for "${entry.selector}" failed.`, error);
467
+ }
468
+ }
469
+ }
470
+ }
471
+
449
472
  function runScan() {
473
+ const nodes = pendingNodes;
474
+ const full = fullScan;
475
+ pendingNodes = [];
476
+ fullScan = false;
477
+
478
+ for (const hook of frameHooks) {
479
+ try {
480
+ hook();
481
+ } catch (error) {
482
+ logError("Frame hook failed.", error);
483
+ }
484
+ }
485
+
450
486
  for (const [selector, entries] of watchers) {
451
- let nodes;
487
+ if (entries.size === 0) continue;
452
488
  try {
453
- nodes = document.querySelectorAll(selector);
489
+ if (full) {
490
+ visitMatches(entries, document.querySelectorAll(selector));
491
+ continue;
492
+ }
493
+ for (const node of nodes) {
494
+ if (!node.isConnected) continue;
495
+ if (node.matches(selector)) visitMatches(entries, [node]);
496
+ if (node.firstElementChild) visitMatches(entries, node.querySelectorAll(selector));
497
+ }
454
498
  } catch (error) {
455
499
  logError(`Invalid selector "${selector}".`, error);
456
- continue;
457
500
  }
458
- for (const node of nodes) {
459
- for (const entry of entries) {
460
- if (entry.seen.has(node)) continue;
461
- entry.seen.add(node);
462
- try {
463
- entry.callback(node);
464
- } catch (error) {
465
- logError(`Watcher for "${selector}" failed.`, error);
466
- }
501
+ }
502
+ }
503
+
504
+ function onMutations(records) {
505
+ if (!fullScan) {
506
+ for (const record of records) {
507
+ const added = record.addedNodes;
508
+ for (let index = 0; index < added.length; index += 1) {
509
+ if (added[index].nodeType === 1) pendingNodes.push(added[index]);
467
510
  }
468
511
  }
512
+ if (pendingNodes.length > FULL_SCAN_THRESHOLD) {
513
+ fullScan = true;
514
+ pendingNodes = [];
515
+ }
469
516
  }
517
+ scheduleScan();
470
518
  }
471
519
 
472
520
  function ensureObserver() {
473
521
  if (observer) return;
474
- observer = new MutationObserver(scheduleScan);
522
+ observer = new MutationObserver(onMutations);
475
523
  observer.observe(document.documentElement, { childList: true, subtree: true });
476
524
  }
477
525
 
478
526
  function watch(selector, callback) {
479
- const entry = { callback, seen: new WeakSet() };
527
+ const entry = { selector, callback, seen: new WeakSet() };
480
528
  let entries = watchers.get(selector);
481
529
  if (!entries) {
482
530
  entries = new Set();
@@ -484,23 +532,37 @@ function watch(selector, callback) {
484
532
  }
485
533
  entries.add(entry);
486
534
  ensureObserver();
487
- scheduleScan();
535
+ try {
536
+ visitMatches([entry], document.querySelectorAll(selector));
537
+ } catch (error) {
538
+ logError(`Invalid selector "${selector}".`, error);
539
+ }
488
540
  return () => {
489
541
  entries.delete(entry);
490
542
  if (entries.size === 0 && watchers.get(selector) === entries) watchers.delete(selector);
491
543
  };
492
544
  }
493
545
 
546
+ function onFrame(hook) {
547
+ frameHooks.add(hook);
548
+ ensureObserver();
549
+ return () => frameHooks.delete(hook);
550
+ }
551
+
494
552
  function waitFor(selector, timeout = 15000) {
495
553
  return new Promise((resolve, reject) => {
496
554
  let unwatch = null;
555
+ let done = false;
497
556
  const timer = setTimeout(() => {
557
+ done = true;
498
558
  unwatch?.();
499
559
  reject(new Error(`Timed out waiting for ${selector}`));
500
560
  }, timeout);
501
561
  unwatch = watch(selector, (element) => {
562
+ if (done) return;
563
+ done = true;
502
564
  clearTimeout(timer);
503
- unwatch();
565
+ queueMicrotask(() => unwatch?.());
504
566
  resolve(element);
505
567
  });
506
568
  });
@@ -517,6 +579,10 @@ function addStyle(name, css) {
517
579
  return () => element.remove();
518
580
  }
519
581
 
582
+ /* ------------------------------------------------------------------------------------------------
583
+ * LCU helpers
584
+ * ---------------------------------------------------------------------------------------------- */
585
+
520
586
  const LCU_TIMEOUT_MS = 10000;
521
587
 
522
588
  function lcuGet(path, { ttl = 0 } = {}) {
@@ -539,9 +605,10 @@ function lcuGet(path, { ttl = 0 } = {}) {
539
605
  () => {
540
606
  entry.pending = false;
541
607
  entry.expires = Date.now() + ttl;
608
+ if (ttl <= 0 && requestCache.get(path) === entry) requestCache.delete(path);
542
609
  },
543
610
  () => {
544
- requestCache.delete(path);
611
+ if (requestCache.get(path) === entry) requestCache.delete(path);
545
612
  },
546
613
  );
547
614
  return entry.promise;
@@ -554,7 +621,14 @@ async function lcuSend(method, path, body) {
554
621
  body: body === undefined ? undefined : JSON.stringify(body),
555
622
  });
556
623
  if (!response.ok) throw new Error(`LCU ${method} ${path} responded ${response.status}`);
557
- return response.status === 204 ? null : response.json();
624
+ if (response.status === 204) return null;
625
+ const text = await response.text();
626
+ if (!text) return null;
627
+ try {
628
+ return JSON.parse(text);
629
+ } catch {
630
+ return text;
631
+ }
558
632
  }
559
633
 
560
634
  function subscribe(uri, listener) {
@@ -565,13 +639,28 @@ function subscribe(uri, listener) {
565
639
 
566
640
  function whenRcpReady(name, timeout = 15000) {
567
641
  return new Promise((resolve, reject) => {
568
- const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${name}`)), timeout);
642
+ let settled = false;
643
+ const timer = setTimeout(() => {
644
+ settled = true;
645
+ reject(new Error(`Timed out waiting for ${name}`));
646
+ }, timeout);
569
647
  const attempt = () => {
648
+ if (settled) return;
570
649
  if (window.rcp?.whenReady) {
571
- window.rcp.whenReady(name).then((api) => {
572
- clearTimeout(timer);
573
- resolve(api);
574
- });
650
+ window.rcp.whenReady(name).then(
651
+ (api) => {
652
+ if (settled) return;
653
+ settled = true;
654
+ clearTimeout(timer);
655
+ resolve(api);
656
+ },
657
+ (error) => {
658
+ if (settled) return;
659
+ settled = true;
660
+ clearTimeout(timer);
661
+ reject(error);
662
+ },
663
+ );
575
664
  } else {
576
665
  setTimeout(attempt, 250);
577
666
  }
@@ -580,6 +669,172 @@ function whenRcpReady(name, timeout = 15000) {
580
669
  });
581
670
  }
582
671
 
672
+ /* ------------------------------------------------------------------------------------------------
673
+ * Data binding transforms.
674
+ *
675
+ * Every client plugin (champ select, skins picker, profiles, ...) reads LCU data through its own
676
+ * "identified" DataBinding from rcp-fe-common-libs, and each binding keeps a private ResourceCache.
677
+ * Plugins register a transform for one identifier + path. The transform runs on every value that
678
+ * enters that binding's cache (HTTP reads and WebSocket updates alike), so only that one client
679
+ * plugin sees the modified data. No global fetch/XHR/WebSocket wrapping and no timing windows.
680
+ * ---------------------------------------------------------------------------------------------- */
681
+
682
+ const bindingRules = new Set();
683
+ const bindingCaches = new Set();
684
+ let bindingSetup = null;
685
+
686
+ function ruleAppliesTo(rule, owner) {
687
+ return Boolean(owner) && rule.identifier === owner.identifier && (!rule.base || rule.base === owner.base);
688
+ }
689
+
690
+ function rulesFor(owner) {
691
+ if (!owner) return null;
692
+ const list = [];
693
+ for (const rule of bindingRules) if (ruleAppliesTo(rule, owner)) list.push(rule);
694
+ return list.length ? list : null;
695
+ }
696
+
697
+ // Transforms must not mutate their input and must be idempotent: re-applying one to data it already
698
+ // produced gives the same result. That lets cached data be re-transformed in place without keeping a copy.
699
+ function applyRules(rules, path, data) {
700
+ let out = data;
701
+ for (const rule of rules) {
702
+ let hit = false;
703
+ try {
704
+ hit = rule.match(path);
705
+ } catch {}
706
+ if (!hit) continue;
707
+ try {
708
+ const next = rule.transform(out, path);
709
+ if (next !== undefined) out = next;
710
+ } catch (error) {
711
+ logError(`Data transform for ${path} failed.`, error);
712
+ }
713
+ }
714
+ return out;
715
+ }
716
+
717
+ // reload: ask the client to fetch the real data again (used when a transform is removed).
718
+ function rewriteCachedPaths(cache, predicate, reload = false) {
719
+ const data = cache._data;
720
+ if (!(data instanceof Map)) return;
721
+ for (const [path, entry] of [...data]) {
722
+ if (!entry || entry.data == null || !predicate(path)) continue;
723
+ try {
724
+ if (reload) cache._resourceRead(path);
725
+ else cache._cacheWrite(path, entry.data, entry.timestamp);
726
+ } catch (error) {
727
+ logError(`Could not refresh ${path}.`, error);
728
+ }
729
+ }
730
+ }
731
+
732
+ function adoptCache(cache, binding) {
733
+ const owner = { identifier: binding?.options?.identifier ?? null, base: binding?.basePath ?? null };
734
+ if (!owner.identifier) {
735
+ cache.__perfOwner = null;
736
+ return;
737
+ }
738
+ cache.__perfOwner = owner;
739
+ cache.__perfRules = rulesFor(owner);
740
+ bindingCaches.add(cache);
741
+ if (cache.__perfRules) {
742
+ const rules = cache.__perfRules;
743
+ rewriteCachedPaths(cache, (path) => rules.some((rule) => rule.match(path)));
744
+ }
745
+ }
746
+
747
+ function setupBindings() {
748
+ if (bindingSetup) return bindingSetup;
749
+ bindingSetup = (async () => {
750
+ const libs = await whenRcpReady("rcp-fe-common-libs", 60000);
751
+ const factory = await libs.getDataBinding("rcp-fe-perfectionism");
752
+ const probe = factory("/perfectionism-probe");
753
+ const bindingProto = Object.getPrototypeOf(probe);
754
+ const probeCache = probe._getCache("/perfectionism-probe/state");
755
+ const cacheProto = probeCache ? Object.getPrototypeOf(probeCache) : null;
756
+ if (
757
+ typeof bindingProto?._getCache !== "function" ||
758
+ typeof cacheProto?._cacheWrite !== "function" ||
759
+ !(probeCache._data instanceof Map)
760
+ ) {
761
+ throw new Error("The client's data binding layout changed.");
762
+ }
763
+ if (!bindingProto._getCache.__perfPatched) {
764
+ const getCache = bindingProto._getCache;
765
+ const patchedGetCache = function (path) {
766
+ const cache = getCache.call(this, path);
767
+ if (cache && typeof cache === "object") {
768
+ const owner = cache.__perfOwner;
769
+ if (owner === undefined) adoptCache(cache, this);
770
+ else if (owner && owner.identifier !== this.options?.identifier) {
771
+ // Shared cache (static assets): never transform it.
772
+ cache.__perfOwner = null;
773
+ cache.__perfRules = null;
774
+ bindingCaches.delete(cache);
775
+ }
776
+ }
777
+ return cache;
778
+ };
779
+ patchedGetCache.__perfPatched = true;
780
+ bindingProto._getCache = patchedGetCache;
781
+
782
+ const write = cacheProto._cacheWrite;
783
+ const patchedWrite = function (path, data, stamp, error) {
784
+ const rules = this.__perfRules;
785
+ if (rules && !error && data != null) data = applyRules(rules, path, data);
786
+ return write.call(this, path, data, stamp, error);
787
+ };
788
+ patchedWrite.__perfPatched = true;
789
+ cacheProto._cacheWrite = patchedWrite;
790
+ }
791
+ })();
792
+ bindingSetup.catch((error) => logError("Data binding hooks are unavailable.", error));
793
+ return bindingSetup;
794
+ }
795
+
796
+ function refreshRule(rule, removed = false) {
797
+ for (const cache of bindingCaches) {
798
+ const owner = cache.__perfOwner;
799
+ if (!ruleAppliesTo(rule, owner)) continue;
800
+ cache.__perfRules = rulesFor(owner);
801
+ const matches = (path) => {
802
+ try {
803
+ return rule.match(path);
804
+ } catch {
805
+ return false;
806
+ }
807
+ };
808
+ rewriteCachedPaths(cache, matches, removed);
809
+ }
810
+ }
811
+
812
+ function transformBinding({ identifier, base = null, match, transform }) {
813
+ if (!identifier || typeof match !== "function" || typeof transform !== "function") {
814
+ throw new Error("[Perfectionism] transformBinding needs an identifier, match() and transform().");
815
+ }
816
+ const rule = { identifier, base, match, transform };
817
+ bindingRules.add(rule);
818
+ const ready = setupBindings().then(() => {
819
+ if (bindingRules.has(rule)) refreshRule(rule);
820
+ });
821
+ ready.catch(() => {});
822
+ return {
823
+ ready,
824
+ reapply() {
825
+ if (bindingRules.has(rule)) refreshRule(rule);
826
+ },
827
+ dispose() {
828
+ if (!bindingRules.delete(rule)) return;
829
+ refreshRule(rule, true);
830
+ },
831
+ };
832
+ }
833
+
834
+ /* ------------------------------------------------------------------------------------------------
835
+ * Settings page
836
+ * ---------------------------------------------------------------------------------------------- */
837
+
583
838
  function el(tag, className, text) {
584
839
  const node = document.createElement(tag);
585
840
  if (className) node.className = className;
@@ -587,22 +842,60 @@ function el(tag, className, text) {
587
842
  return node;
588
843
  }
589
844
 
590
- const optionGroupState = new Map();
591
-
592
- function buildOptionRow(mod, option, disabled, onChange) {
593
- const row = el("label", "perf-opt perf-toggle");
594
- row.title = option.description ?? "";
845
+ // The client's own checkbox element. `label` may be empty (the plugin's Enabled box).
846
+ function buildCheckbox(label, checked, disabled, onChange, title) {
847
+ const box = el("lol-uikit-flat-checkbox");
848
+ if (checked) box.classList.add("checked");
849
+ if (disabled) box.classList.add("perf-disabled");
850
+ if (title) box.title = title;
595
851
  const input = el("input");
852
+ input.slot = "input";
596
853
  input.type = "checkbox";
597
- input.checked = getOption(mod.id, option.key);
854
+ input.checked = Boolean(checked);
598
855
  input.disabled = Boolean(disabled);
599
- input.setAttribute("aria-label", `${mod.name}: ${option.label}`);
856
+ if (title) input.setAttribute("aria-label", title);
857
+ box.append(input);
858
+ if (label) {
859
+ const text = el("label", null, label);
860
+ text.slot = "label";
861
+ box.append(text);
862
+ }
600
863
  input.addEventListener("change", () => {
601
- if (onChange) onChange(input.checked);
602
- else setOption(mod.id, option.key, input.checked, false);
864
+ box.classList.toggle("checked", input.checked);
865
+ onChange(input.checked);
603
866
  });
604
- row.append(input, el("span", "perf-box"), el("span", "perf-opt-label", option.label));
605
- return row;
867
+ return box;
868
+ }
869
+
870
+ function setCheckboxDisabled(box, disabled) {
871
+ const input = box?.querySelector("input");
872
+ if (!input) return;
873
+ input.disabled = Boolean(disabled);
874
+ box.classList.toggle("perf-disabled", Boolean(disabled));
875
+ }
876
+
877
+ function buildButton(tag, text, onClick, title) {
878
+ const button = el(tag, null, text);
879
+ if (title) button.title = title;
880
+ button.addEventListener("click", (event) => {
881
+ event.preventDefault();
882
+ event.stopPropagation();
883
+ if (button.hasAttribute("disabled")) return;
884
+ onClick(button);
885
+ });
886
+ return button;
887
+ }
888
+
889
+ const optionGroupState = new Map();
890
+
891
+ function buildOptionRow(mod, option, disabled, onChange) {
892
+ return buildCheckbox(
893
+ option.label,
894
+ getOption(mod.id, option.key),
895
+ disabled,
896
+ (checked) => (onChange ? onChange(checked) : setOption(mod.id, option.key, checked, false)),
897
+ option.description,
898
+ );
606
899
  }
607
900
 
608
901
  function buildChips(mod, option, locked) {
@@ -637,7 +930,7 @@ function buildOptions(mod, locked) {
637
930
  continue;
638
931
  }
639
932
  if (!option.children?.length) {
640
- wrap.append(buildOptionRow(mod, option, Boolean(locked)));
933
+ wrap.append(buildOptionRow(mod, option, locked));
641
934
  continue;
642
935
  }
643
936
  const group = el("div", "perf-opt-group");
@@ -646,14 +939,10 @@ function buildOptions(mod, locked) {
646
939
  caret.setAttribute("role", "button");
647
940
  caret.setAttribute("aria-label", `Show ${mod.name} options`);
648
941
  const kids = el("div", "perf-opt-children");
649
- const childInputs = [];
650
- for (const child of option.children) {
651
- const childRow = buildOptionRow(mod, child, !getOption(mod.id, option.key) || Boolean(locked));
652
- childInputs.push(childRow.querySelector("input"));
653
- kids.append(childRow);
654
- }
655
- const master = buildOptionRow(mod, option, Boolean(locked), (checked) => {
656
- for (const input of childInputs) input.disabled = !checked || Boolean(locked);
942
+ const childBoxes = option.children.map((child) => buildOptionRow(mod, child, locked || !getOption(mod.id, option.key)));
943
+ kids.append(...childBoxes);
944
+ const master = buildOptionRow(mod, option, locked, (checked) => {
945
+ for (const box of childBoxes) setCheckboxDisabled(box, !checked || locked);
657
946
  setOption(mod.id, option.key, checked, false);
658
947
  });
659
948
  const groupKey = `${mod.id}:${option.key}`;
@@ -675,44 +964,6 @@ function buildOptions(mod, locked) {
675
964
  return wrap;
676
965
  }
677
966
 
678
- function buildToggle(mod, locked) {
679
- const label = el("label", "perf-toggle");
680
- label.title = `Enable ${mod.name}`;
681
- const input = el("input");
682
- input.type = "checkbox";
683
- input.checked = isPluginEnabled(mod.id);
684
- input.disabled = Boolean(locked);
685
- input.setAttribute("aria-label", `Enable ${mod.name}`);
686
- input.addEventListener("change", () => setPluginEnabled(mod.id, input.checked));
687
- label.append(input, el("span", "perf-box"));
688
- return label;
689
- }
690
-
691
- function buildInstallButton(mod) {
692
- const button = el("button", "perf-btn perf-install", "Install");
693
- button.title = `Install ${mod.name}`;
694
- button.addEventListener("click", async () => {
695
- button.disabled = true;
696
- button.textContent = "Installing";
697
- try {
698
- await installModule(mod);
699
- } catch (error) {
700
- logError(`could not install ${mod.name}.`, error);
701
- button.disabled = false;
702
- button.textContent = "Install";
703
- }
704
- });
705
- return button;
706
- }
707
-
708
- function buildUninstallButton(mod) {
709
- const button = el("button", "perf-btn perf-uninstall");
710
- button.title = `Uninstall ${mod.name}`;
711
- button.setAttribute("aria-label", `Uninstall ${mod.name}`);
712
- button.addEventListener("click", () => uninstallModule(mod).catch((error) => logError(`could not uninstall ${mod.name}.`, error)));
713
- return button;
714
- }
715
-
716
967
  function installedModules() {
717
968
  return getAvailableModules().filter(isInstalled);
718
969
  }
@@ -765,7 +1016,9 @@ function applySearchFilter(panel = document.getElementById(PANEL_ID)) {
765
1016
  function buildTools() {
766
1017
  const tools = el("div", "perf-tools");
767
1018
 
768
- const search = el("input", "perf-search");
1019
+ const field = el("lol-uikit-flat-input");
1020
+ field.setAttribute("type", "search");
1021
+ const search = el("input");
769
1022
  search.type = "search";
770
1023
  search.placeholder = "Search plugins...";
771
1024
  search.value = searchQuery;
@@ -774,16 +1027,15 @@ function buildTools() {
774
1027
  searchQuery = search.value.trim().toLowerCase();
775
1028
  applySearchFilter();
776
1029
  });
1030
+ field.append(search);
777
1031
 
778
- const master = el("button", "perf-btn perf-master", masterLocked() ? "Enable All" : "Disable All");
779
- master.type = "button";
780
- master.disabled = installedModules().length === 0;
781
- master.addEventListener("click", () => {
1032
+ const master = buildButton("lol-uikit-flat-button-secondary", masterLocked() ? "Enable All" : "Disable All", () => {
782
1033
  if (masterLocked()) enableAllPlugins();
783
1034
  else disableAllPlugins();
784
1035
  });
1036
+ if (installedModules().length === 0) master.setAttribute("disabled", "true");
785
1037
 
786
- tools.append(search, master);
1038
+ tools.append(field, master);
787
1039
  return tools;
788
1040
  }
789
1041
 
@@ -802,8 +1054,32 @@ function buildCard(mod, locked) {
802
1054
  if (optionList) info.append(optionList);
803
1055
 
804
1056
  const actions = el("div", "perf-actions");
805
- if (installed) actions.append(buildToggle(mod, locked));
806
- actions.append(installed ? buildUninstallButton(mod) : buildInstallButton(mod));
1057
+ if (installed) {
1058
+ actions.append(buildCheckbox("", isPluginEnabled(mod.id), locked, (checked) => setPluginEnabled(mod.id, checked), `Enable ${mod.name}`));
1059
+ const remove = el("button", "perf-uninstall");
1060
+ remove.type = "button";
1061
+ remove.title = `Uninstall ${mod.name}`;
1062
+ remove.setAttribute("aria-label", `Uninstall ${mod.name}`);
1063
+ remove.addEventListener("click", () => uninstallModule(mod).catch((error) => logError(`could not uninstall ${mod.name}.`, error)));
1064
+ actions.append(remove);
1065
+ } else {
1066
+ actions.append(
1067
+ buildButton(
1068
+ "lol-uikit-flat-button-secondary",
1069
+ "Install",
1070
+ (button) => {
1071
+ button.setAttribute("disabled", "true");
1072
+ button.textContent = "Installing";
1073
+ installModule(mod).catch((error) => {
1074
+ logError(`could not install ${mod.name}.`, error);
1075
+ button.removeAttribute("disabled");
1076
+ button.textContent = "Install";
1077
+ });
1078
+ },
1079
+ `Install ${mod.name}`,
1080
+ ),
1081
+ );
1082
+ }
807
1083
 
808
1084
  card.append(info, actions);
809
1085
  return card;
@@ -813,8 +1089,12 @@ function buildPanel() {
813
1089
  const panel = el("div");
814
1090
  panel.id = PANEL_ID;
815
1091
  panel.append(buildTools());
1092
+ if (needsReload()) {
1093
+ panel.append(el("div", "lol-settings-general-subtitle perf-notice", "Press Done to reload the client and apply your changes."));
1094
+ }
816
1095
 
817
1096
  const scroller = el("lol-uikit-scrollable");
1097
+ scroller.setAttribute("overflow-masks", "enabled");
818
1098
  panel.append(scroller);
819
1099
 
820
1100
  const modules = getAvailableModules();
@@ -842,142 +1122,102 @@ function refreshPanel() {
842
1122
  const current = document.getElementById(PANEL_ID);
843
1123
  if (!current) return;
844
1124
  const top = panelScrollTop(current);
1125
+ const focused = document.activeElement?.closest?.(`#${PANEL_ID} .perf-toolbar`) ? document.activeElement : null;
845
1126
  const next = buildPanel();
846
1127
  current.replaceWith(next);
1128
+ if (panel) panel.element = next;
1129
+ if (focused) next.querySelector(".perf-toolbar input")?.focus();
847
1130
  if (!top) return;
848
1131
  applyScrollTop(next, top);
849
1132
  requestAnimationFrame(() => applyScrollTop(next, top));
850
1133
  }
851
1134
 
852
- function settingsNavScope() {
853
- const title =
854
- document.querySelector(".lol-settings-nav-title:not([data-perfectionist])") ??
855
- document.querySelector(".lol-settings-nav-title");
856
- return title?.closest("lol-uikit-scrollable") ?? title?.parentElement ?? null;
857
- }
1135
+ // The native settings modal is not destroyed when it closes: the client detaches it and re-attaches the
1136
+ // same DOM on the next open. While our page is open, a class on the settings container hides the client's
1137
+ // options pane and tab highlight and swaps the breadcrumb, all in CSS, so nothing the client renders is
1138
+ // ever modified. `panel` holds direct references, so it can be torn down even after the modal is detached.
1139
+ let panel = null;
858
1140
 
859
- function navItems() {
860
- const scope = settingsNavScope();
861
- if (!scope) return [];
862
- return [...scope.querySelectorAll('lol-uikit-navigation-item, [class*="settings-nav"]')];
863
- }
864
-
865
- function setNavActive(activeNav) {
866
- for (const nav of navItems()) {
867
- if (nav === activeNav) {
868
- if (nav.getAttribute("active") !== "true") nav.setAttribute("active", "true");
869
- } else if (nav.hasAttribute("active")) {
870
- nav.removeAttribute("active");
871
- }
872
- }
1141
+ function ownNav() {
1142
+ return document.getElementById(NAV_ID);
873
1143
  }
874
1144
 
875
- let panelRequested = false;
876
- let syncTimer = null;
877
- let navObserver = null;
878
- let nativeActiveBeforeOpen = null;
879
-
880
- function suppressNativeNavActive() {
881
- const own = document.getElementById(NAV_ID);
882
- if (!own) return;
883
- if (!panelRequested) {
884
- if (own.hasAttribute("active")) own.removeAttribute("active");
885
- return;
1145
+ function measureCrumb(container) {
1146
+ const group = container.querySelector(".lol-settings-header-group-name");
1147
+ const current = container.querySelector(".lol-settings-title-current");
1148
+ if (group) {
1149
+ const style = getComputedStyle(group);
1150
+ container.style.setProperty("--perf-crumb-group-size", style.fontSize);
1151
+ container.style.setProperty("--perf-crumb-group-spacing", style.letterSpacing);
886
1152
  }
887
- for (const nav of navItems()) {
888
- if (nav !== own && nav.hasAttribute("active")) nav.removeAttribute("active");
1153
+ if (current) {
1154
+ const style = getComputedStyle(current);
1155
+ container.style.setProperty("--perf-crumb-size", style.fontSize);
1156
+ container.style.setProperty("--perf-crumb-spacing", style.letterSpacing);
889
1157
  }
890
- }
891
-
892
- function watchNavActive() {
893
- if (navObserver || !document.body) return;
894
- navObserver = new MutationObserver(suppressNativeNavActive);
895
- navObserver.observe(document.body, { subtree: true, attributes: true, attributeFilter: ["active"] });
896
- }
897
-
898
- function startSync() {
899
- if (syncTimer === null) syncTimer = setInterval(keepPanelInSync, 300);
900
- }
901
-
902
- function stopSync() {
903
- if (syncTimer === null) return;
904
- clearInterval(syncTimer);
905
- syncTimer = null;
1158
+ const idle = container.querySelector(`lol-uikit-navigation-item.lol-settings-nav:not(#${NAV_ID}):not([active])`);
1159
+ if (idle) container.style.setProperty("--perf-nav-pad", getComputedStyle(idle).paddingLeft);
906
1160
  }
907
1161
 
908
1162
  function openPanel() {
909
- panelRequested = true;
910
- startSync();
911
1163
  const content = document.querySelector(".lol-settings-content");
912
- if (!content) return;
913
-
914
- const options = content.querySelector(".lol-settings-options");
915
- if (options) options.style.display = "none";
916
-
917
- const existing = document.getElementById(PANEL_ID);
918
- if (existing) existing.remove();
919
- content.append(buildPanel());
920
-
921
- const ownNav = document.getElementById(NAV_ID);
922
- if (!nativeActiveBeforeOpen) {
923
- nativeActiveBeforeOpen = navItems().find((nav) => nav !== ownNav && nav.hasAttribute("active")) ?? null;
1164
+ const container = content?.closest(".lol-settings-container");
1165
+ if (!content || !container) return;
1166
+ if (panel && panel.content !== content) closePanel();
1167
+ if (!panel) {
1168
+ measureCrumb(container);
1169
+ panel = { content, container, element: null, nav: ownNav() };
924
1170
  }
925
- setNavActive(ownNav);
926
- const currentTitle = document.querySelector(".lol-settings-title-current");
927
- if (currentTitle) currentTitle.textContent = TAB_TITLE.toUpperCase();
1171
+ syncPanel();
928
1172
  }
929
1173
 
930
- function closePanel() {
931
- panelRequested = false;
932
- const panel = document.getElementById(PANEL_ID);
1174
+ function syncPanel() {
933
1175
  if (!panel) return;
934
- panel.remove();
935
- const options = document.querySelector(".lol-settings-content .lol-settings-options");
936
- if (options) options.style.display = "";
937
- document.getElementById(NAV_ID)?.removeAttribute("active");
938
- if (nativeActiveBeforeOpen?.isConnected) nativeActiveBeforeOpen.setAttribute("active", "true");
939
- nativeActiveBeforeOpen = null;
940
- }
941
-
942
- function keepPanelInSync() {
943
- const content = document.querySelector(".lol-settings-content");
944
- if (!content) {
945
- panelRequested = false;
946
- stopSync();
947
- document.getElementById(NAV_ID)?.removeAttribute("active");
948
- return;
949
- }
950
- if (!panelRequested) {
951
- const staleNav = document.getElementById(NAV_ID);
952
- if (staleNav && staleNav.hasAttribute("active")) staleNav.removeAttribute("active");
1176
+ const { content, container } = panel;
1177
+ if (!content.isConnected) {
1178
+ closePanel();
953
1179
  return;
954
1180
  }
955
-
956
- const panel = document.getElementById(PANEL_ID);
957
- if (!panel || panel.parentElement !== content) {
958
- openPanel();
959
- return;
1181
+ if (!container.classList.contains(OPEN_CLASS)) container.classList.add(OPEN_CLASS);
1182
+ if (!panel.element || !panel.element.isConnected || panel.element.parentElement !== content) {
1183
+ panel.element?.remove();
1184
+ panel.element = buildPanel();
1185
+ content.append(panel.element);
960
1186
  }
1187
+ const nav = ownNav();
1188
+ panel.nav = nav;
1189
+ if (nav && nav.getAttribute("active") !== "true") nav.setAttribute("active", "true");
1190
+ }
961
1191
 
962
- const options = content.querySelector(".lol-settings-options");
963
- if (options && options.style.display !== "none") options.style.display = "none";
964
-
965
- const nav = document.getElementById(NAV_ID);
966
- if (nav) setNavActive(nav);
1192
+ // Runs in the capture phase of a native tab click (before the client handles it), and when the settings
1193
+ // modal is detached, so the next open starts clean on the client's own tab.
1194
+ function closePanel() {
1195
+ if (!panel) return;
1196
+ const closing = panel;
1197
+ panel = null;
1198
+ const current = document.getElementById(PANEL_ID);
1199
+ if (current && current !== closing.element) current.remove();
1200
+ closing.element?.remove();
1201
+ closing.container.classList.remove(OPEN_CLASS);
1202
+ closing.nav?.removeAttribute("active");
1203
+ ownNav()?.removeAttribute("active");
1204
+ }
967
1205
 
968
- const title = document.querySelector(".lol-settings-title-current");
969
- if (title && title.textContent !== TAB_TITLE.toUpperCase()) title.textContent = TAB_TITLE.toUpperCase();
1206
+ function keepOwnNavQuiet(nav) {
1207
+ // The uikit navigation bar auto-selects its first item; ours must only light up while our page is open.
1208
+ const quiet = new MutationObserver(() => {
1209
+ if (!panel && nav.hasAttribute("active")) nav.removeAttribute("active");
1210
+ });
1211
+ quiet.observe(nav, { attributes: true, attributeFilter: ["active"] });
970
1212
  }
971
1213
 
972
1214
  function ensureSettingsTab(titleNode) {
973
- startSync();
974
- if (document.getElementById(NAV_ID)) return;
975
-
1215
+ if (ownNav()) return;
976
1216
  const scope = titleNode.closest("lol-uikit-scrollable") ?? titleNode.parentElement;
977
- const firstTitle = scope?.querySelector(".lol-settings-nav-title");
1217
+ const firstTitle = scope?.querySelector(".lol-settings-nav-title:not([data-perfectionist])");
978
1218
  if (!firstTitle) return;
979
1219
 
980
- const groupTitle = el("div", "lol-settings-nav-title", "Plugins");
1220
+ const groupTitle = el("div", "lol-settings-nav-title", GROUP_TITLE);
981
1221
  groupTitle.dataset.perfectionist = "title";
982
1222
 
983
1223
  const item = document.createElement("lol-uikit-navigation-item");
@@ -989,50 +1229,47 @@ function ensureSettingsTab(titleNode) {
989
1229
  const bar = document.createElement("lol-uikit-navigation-bar");
990
1230
  bar.setAttribute("direction", "down");
991
1231
  bar.setAttribute("type", "tabbed");
1232
+ bar.setAttribute("selectedindex", "-1");
992
1233
  bar.dataset.perfectionist = "bar";
993
1234
  bar.append(item);
994
1235
 
995
1236
  firstTitle.before(groupTitle, bar);
1237
+ keepOwnNavQuiet(item);
1238
+ if (!panel) item.removeAttribute("active");
996
1239
  }
997
1240
 
998
1241
  function isDoneButton(target) {
1242
+ if (target.closest(".lol-settings-close-button")) return true;
999
1243
  const button = target.closest("button, lol-uikit-flat-button, [role=button]");
1000
- return !!button && /^\s*done\s*$/i.test(button.textContent ?? "");
1244
+ return !!button && !!button.closest(".lol-settings-container") && /^\s*done\s*$/i.test(button.textContent ?? "");
1245
+ }
1246
+
1247
+ function onSettingsClick(event) {
1248
+ const target = event.target;
1249
+ if (!(target instanceof Element)) return;
1250
+ if (needsReload() && isDoneButton(target)) {
1251
+ pendingReload = false;
1252
+ unloadPending.clear();
1253
+ reloadClientNow();
1254
+ return;
1255
+ }
1256
+ if (target.closest(`#${NAV_ID}`)) {
1257
+ event.stopPropagation();
1258
+ event.preventDefault();
1259
+ openPanel();
1260
+ return;
1261
+ }
1262
+ if (!panel) return;
1263
+ const nativeNav = target.closest(".lol-settings-nav");
1264
+ if (nativeNav && nativeNav.id !== NAV_ID && nativeNav.closest(".lol-settings-container")) closePanel();
1001
1265
  }
1002
1266
 
1003
1267
  function installSettingsTab() {
1004
1268
  addStyle(STYLE_ID, STYLE_CSS);
1005
- watchNavActive();
1006
-
1007
- document.addEventListener(
1008
- "click",
1009
- (event) => {
1010
- const target = event.target;
1011
- if (!(target instanceof Element)) return;
1012
- if (needsReload() && isDoneButton(target)) {
1013
- pendingReload = false;
1014
- unloadPending.clear();
1015
- reloadClientNow();
1016
- return;
1017
- }
1018
- if (target.closest(`#${NAV_ID}`)) {
1019
- event.stopPropagation();
1020
- openPanel();
1021
- return;
1022
- }
1023
- const nativeNav = target.closest(".lol-settings-nav");
1024
- if (nativeNav && document.getElementById(PANEL_ID)) {
1025
- closePanel();
1026
- const label = nativeNav.textContent.trim();
1027
- setTimeout(() => {
1028
- const title = document.querySelector(".lol-settings-title-current");
1029
- if (title && label && !document.getElementById(PANEL_ID)) title.textContent = label;
1030
- }, 0);
1031
- }
1032
- },
1033
- true,
1034
- );
1035
-
1269
+ document.addEventListener("click", onSettingsClick, true);
1270
+ onFrame(() => {
1271
+ if (panel) syncPanel();
1272
+ });
1036
1273
  watch(".lol-settings-nav-title", ensureSettingsTab);
1037
1274
  }
1038
1275
 
@@ -1050,10 +1287,12 @@ const utils = {
1050
1287
  uninstallModule,
1051
1288
  watch,
1052
1289
  waitFor,
1290
+ onFrame,
1053
1291
  addStyle,
1054
1292
  lcu: { get: lcuGet, send: lcuSend },
1055
1293
  subscribe,
1056
1294
  whenRcpReady,
1295
+ transformBinding,
1057
1296
  };
1058
1297
 
1059
1298
  export async function init(context, coordsProvided) {
@@ -1075,4 +1314,4 @@ export async function init(context, coordsProvided) {
1075
1314
  } catch (error) {
1076
1315
  logError("startup failed.", error);
1077
1316
  }
1078
- }
1317
+ }