perfectionist-plugins 2.4.8 → 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.6",
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.6",
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.5",
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.13",
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
  }
@@ -426,10 +430,9 @@ async function loadModule(mod) {
426
430
  }
427
431
 
428
432
  async function loadEnabledModules() {
429
- for (const mod of BUNDLED_MODULES) {
430
- if (mod.core || !isInstalled(mod) || !isPluginEnabled(mod.id)) continue;
431
- await loadModule(mod);
432
- }
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)));
433
436
  }
434
437
 
435
438
  async function loadModules() {
@@ -438,6 +441,11 @@ async function loadModules() {
438
441
  await loadEnabledModules();
439
442
  }
440
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
+
441
449
  function scheduleScan() {
442
450
  if (scanScheduled) return;
443
451
  scanScheduled = true;
@@ -447,37 +455,76 @@ function scheduleScan() {
447
455
  });
448
456
  }
449
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
+
450
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
+
451
486
  for (const [selector, entries] of watchers) {
452
- let nodes;
487
+ if (entries.size === 0) continue;
453
488
  try {
454
- 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
+ }
455
498
  } catch (error) {
456
499
  logError(`Invalid selector "${selector}".`, error);
457
- continue;
458
500
  }
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 "${selector}" failed.`, error);
467
- }
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]);
468
510
  }
469
511
  }
512
+ if (pendingNodes.length > FULL_SCAN_THRESHOLD) {
513
+ fullScan = true;
514
+ pendingNodes = [];
515
+ }
470
516
  }
517
+ scheduleScan();
471
518
  }
472
519
 
473
520
  function ensureObserver() {
474
521
  if (observer) return;
475
- observer = new MutationObserver(scheduleScan);
522
+ observer = new MutationObserver(onMutations);
476
523
  observer.observe(document.documentElement, { childList: true, subtree: true });
477
524
  }
478
525
 
479
526
  function watch(selector, callback) {
480
- const entry = { callback, seen: new WeakSet() };
527
+ const entry = { selector, callback, seen: new WeakSet() };
481
528
  let entries = watchers.get(selector);
482
529
  if (!entries) {
483
530
  entries = new Set();
@@ -485,23 +532,37 @@ function watch(selector, callback) {
485
532
  }
486
533
  entries.add(entry);
487
534
  ensureObserver();
488
- scheduleScan();
535
+ try {
536
+ visitMatches([entry], document.querySelectorAll(selector));
537
+ } catch (error) {
538
+ logError(`Invalid selector "${selector}".`, error);
539
+ }
489
540
  return () => {
490
541
  entries.delete(entry);
491
542
  if (entries.size === 0 && watchers.get(selector) === entries) watchers.delete(selector);
492
543
  };
493
544
  }
494
545
 
546
+ function onFrame(hook) {
547
+ frameHooks.add(hook);
548
+ ensureObserver();
549
+ return () => frameHooks.delete(hook);
550
+ }
551
+
495
552
  function waitFor(selector, timeout = 15000) {
496
553
  return new Promise((resolve, reject) => {
497
554
  let unwatch = null;
555
+ let done = false;
498
556
  const timer = setTimeout(() => {
557
+ done = true;
499
558
  unwatch?.();
500
559
  reject(new Error(`Timed out waiting for ${selector}`));
501
560
  }, timeout);
502
561
  unwatch = watch(selector, (element) => {
562
+ if (done) return;
563
+ done = true;
503
564
  clearTimeout(timer);
504
- unwatch();
565
+ queueMicrotask(() => unwatch?.());
505
566
  resolve(element);
506
567
  });
507
568
  });
@@ -518,6 +579,10 @@ function addStyle(name, css) {
518
579
  return () => element.remove();
519
580
  }
520
581
 
582
+ /* ------------------------------------------------------------------------------------------------
583
+ * LCU helpers
584
+ * ---------------------------------------------------------------------------------------------- */
585
+
521
586
  const LCU_TIMEOUT_MS = 10000;
522
587
 
523
588
  function lcuGet(path, { ttl = 0 } = {}) {
@@ -540,9 +605,10 @@ function lcuGet(path, { ttl = 0 } = {}) {
540
605
  () => {
541
606
  entry.pending = false;
542
607
  entry.expires = Date.now() + ttl;
608
+ if (ttl <= 0 && requestCache.get(path) === entry) requestCache.delete(path);
543
609
  },
544
610
  () => {
545
- requestCache.delete(path);
611
+ if (requestCache.get(path) === entry) requestCache.delete(path);
546
612
  },
547
613
  );
548
614
  return entry.promise;
@@ -555,7 +621,14 @@ async function lcuSend(method, path, body) {
555
621
  body: body === undefined ? undefined : JSON.stringify(body),
556
622
  });
557
623
  if (!response.ok) throw new Error(`LCU ${method} ${path} responded ${response.status}`);
558
- 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
+ }
559
632
  }
560
633
 
561
634
  function subscribe(uri, listener) {
@@ -566,13 +639,28 @@ function subscribe(uri, listener) {
566
639
 
567
640
  function whenRcpReady(name, timeout = 15000) {
568
641
  return new Promise((resolve, reject) => {
569
- 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);
570
647
  const attempt = () => {
648
+ if (settled) return;
571
649
  if (window.rcp?.whenReady) {
572
- window.rcp.whenReady(name).then((api) => {
573
- clearTimeout(timer);
574
- resolve(api);
575
- });
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
+ );
576
664
  } else {
577
665
  setTimeout(attempt, 250);
578
666
  }
@@ -581,6 +669,172 @@ function whenRcpReady(name, timeout = 15000) {
581
669
  });
582
670
  }
583
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
+
584
838
  function el(tag, className, text) {
585
839
  const node = document.createElement(tag);
586
840
  if (className) node.className = className;
@@ -588,22 +842,60 @@ function el(tag, className, text) {
588
842
  return node;
589
843
  }
590
844
 
591
- const optionGroupState = new Map();
592
-
593
- function buildOptionRow(mod, option, disabled, onChange) {
594
- const row = el("label", "perf-opt perf-toggle");
595
- 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;
596
851
  const input = el("input");
852
+ input.slot = "input";
597
853
  input.type = "checkbox";
598
- input.checked = getOption(mod.id, option.key);
854
+ input.checked = Boolean(checked);
599
855
  input.disabled = Boolean(disabled);
600
- 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
+ }
601
863
  input.addEventListener("change", () => {
602
- if (onChange) onChange(input.checked);
603
- else setOption(mod.id, option.key, input.checked, false);
864
+ box.classList.toggle("checked", input.checked);
865
+ onChange(input.checked);
604
866
  });
605
- row.append(input, el("span", "perf-box"), el("span", "perf-opt-label", option.label));
606
- 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
+ );
607
899
  }
608
900
 
609
901
  function buildChips(mod, option, locked) {
@@ -638,7 +930,7 @@ function buildOptions(mod, locked) {
638
930
  continue;
639
931
  }
640
932
  if (!option.children?.length) {
641
- wrap.append(buildOptionRow(mod, option, Boolean(locked)));
933
+ wrap.append(buildOptionRow(mod, option, locked));
642
934
  continue;
643
935
  }
644
936
  const group = el("div", "perf-opt-group");
@@ -647,14 +939,10 @@ function buildOptions(mod, locked) {
647
939
  caret.setAttribute("role", "button");
648
940
  caret.setAttribute("aria-label", `Show ${mod.name} options`);
649
941
  const kids = el("div", "perf-opt-children");
650
- const childInputs = [];
651
- for (const child of option.children) {
652
- const childRow = buildOptionRow(mod, child, !getOption(mod.id, option.key) || Boolean(locked));
653
- childInputs.push(childRow.querySelector("input"));
654
- kids.append(childRow);
655
- }
656
- const master = buildOptionRow(mod, option, Boolean(locked), (checked) => {
657
- 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);
658
946
  setOption(mod.id, option.key, checked, false);
659
947
  });
660
948
  const groupKey = `${mod.id}:${option.key}`;
@@ -676,44 +964,6 @@ function buildOptions(mod, locked) {
676
964
  return wrap;
677
965
  }
678
966
 
679
- function buildToggle(mod, locked) {
680
- const label = el("label", "perf-toggle");
681
- label.title = `Enable ${mod.name}`;
682
- const input = el("input");
683
- input.type = "checkbox";
684
- input.checked = isPluginEnabled(mod.id);
685
- input.disabled = Boolean(locked);
686
- input.setAttribute("aria-label", `Enable ${mod.name}`);
687
- input.addEventListener("change", () => setPluginEnabled(mod.id, input.checked));
688
- label.append(input, el("span", "perf-box"));
689
- return label;
690
- }
691
-
692
- function buildInstallButton(mod) {
693
- const button = el("button", "perf-btn perf-install", "Install");
694
- button.title = `Install ${mod.name}`;
695
- button.addEventListener("click", async () => {
696
- button.disabled = true;
697
- button.textContent = "Installing";
698
- try {
699
- await installModule(mod);
700
- } catch (error) {
701
- logError(`could not install ${mod.name}.`, error);
702
- button.disabled = false;
703
- button.textContent = "Install";
704
- }
705
- });
706
- return button;
707
- }
708
-
709
- function buildUninstallButton(mod) {
710
- const button = el("button", "perf-btn perf-uninstall");
711
- button.title = `Uninstall ${mod.name}`;
712
- button.setAttribute("aria-label", `Uninstall ${mod.name}`);
713
- button.addEventListener("click", () => uninstallModule(mod).catch((error) => logError(`could not uninstall ${mod.name}.`, error)));
714
- return button;
715
- }
716
-
717
967
  function installedModules() {
718
968
  return getAvailableModules().filter(isInstalled);
719
969
  }
@@ -766,7 +1016,9 @@ function applySearchFilter(panel = document.getElementById(PANEL_ID)) {
766
1016
  function buildTools() {
767
1017
  const tools = el("div", "perf-tools");
768
1018
 
769
- const search = el("input", "perf-search");
1019
+ const field = el("lol-uikit-flat-input");
1020
+ field.setAttribute("type", "search");
1021
+ const search = el("input");
770
1022
  search.type = "search";
771
1023
  search.placeholder = "Search plugins...";
772
1024
  search.value = searchQuery;
@@ -775,16 +1027,15 @@ function buildTools() {
775
1027
  searchQuery = search.value.trim().toLowerCase();
776
1028
  applySearchFilter();
777
1029
  });
1030
+ field.append(search);
778
1031
 
779
- const master = el("button", "perf-btn perf-master", masterLocked() ? "Enable All" : "Disable All");
780
- master.type = "button";
781
- master.disabled = installedModules().length === 0;
782
- master.addEventListener("click", () => {
1032
+ const master = buildButton("lol-uikit-flat-button-secondary", masterLocked() ? "Enable All" : "Disable All", () => {
783
1033
  if (masterLocked()) enableAllPlugins();
784
1034
  else disableAllPlugins();
785
1035
  });
1036
+ if (installedModules().length === 0) master.setAttribute("disabled", "true");
786
1037
 
787
- tools.append(search, master);
1038
+ tools.append(field, master);
788
1039
  return tools;
789
1040
  }
790
1041
 
@@ -803,8 +1054,32 @@ function buildCard(mod, locked) {
803
1054
  if (optionList) info.append(optionList);
804
1055
 
805
1056
  const actions = el("div", "perf-actions");
806
- if (installed) actions.append(buildToggle(mod, locked));
807
- 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
+ }
808
1083
 
809
1084
  card.append(info, actions);
810
1085
  return card;
@@ -814,8 +1089,12 @@ function buildPanel() {
814
1089
  const panel = el("div");
815
1090
  panel.id = PANEL_ID;
816
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
+ }
817
1095
 
818
1096
  const scroller = el("lol-uikit-scrollable");
1097
+ scroller.setAttribute("overflow-masks", "enabled");
819
1098
  panel.append(scroller);
820
1099
 
821
1100
  const modules = getAvailableModules();
@@ -843,142 +1122,102 @@ function refreshPanel() {
843
1122
  const current = document.getElementById(PANEL_ID);
844
1123
  if (!current) return;
845
1124
  const top = panelScrollTop(current);
1125
+ const focused = document.activeElement?.closest?.(`#${PANEL_ID} .perf-toolbar`) ? document.activeElement : null;
846
1126
  const next = buildPanel();
847
1127
  current.replaceWith(next);
1128
+ if (panel) panel.element = next;
1129
+ if (focused) next.querySelector(".perf-toolbar input")?.focus();
848
1130
  if (!top) return;
849
1131
  applyScrollTop(next, top);
850
1132
  requestAnimationFrame(() => applyScrollTop(next, top));
851
1133
  }
852
1134
 
853
- function settingsNavScope() {
854
- const title =
855
- document.querySelector(".lol-settings-nav-title:not([data-perfectionist])") ??
856
- document.querySelector(".lol-settings-nav-title");
857
- return title?.closest("lol-uikit-scrollable") ?? title?.parentElement ?? null;
858
- }
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;
859
1140
 
860
- function navItems() {
861
- const scope = settingsNavScope();
862
- if (!scope) return [];
863
- return [...scope.querySelectorAll('lol-uikit-navigation-item, [class*="settings-nav"]')];
864
- }
865
-
866
- function setNavActive(activeNav) {
867
- for (const nav of navItems()) {
868
- if (nav === activeNav) {
869
- if (nav.getAttribute("active") !== "true") nav.setAttribute("active", "true");
870
- } else if (nav.hasAttribute("active")) {
871
- nav.removeAttribute("active");
872
- }
873
- }
1141
+ function ownNav() {
1142
+ return document.getElementById(NAV_ID);
874
1143
  }
875
1144
 
876
- let panelRequested = false;
877
- let syncTimer = null;
878
- let navObserver = null;
879
- let nativeActiveBeforeOpen = null;
880
-
881
- function suppressNativeNavActive() {
882
- const own = document.getElementById(NAV_ID);
883
- if (!own) return;
884
- if (!panelRequested) {
885
- if (own.hasAttribute("active")) own.removeAttribute("active");
886
- 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);
887
1152
  }
888
- for (const nav of navItems()) {
889
- 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);
890
1157
  }
891
- }
892
-
893
- function watchNavActive() {
894
- if (navObserver || !document.body) return;
895
- navObserver = new MutationObserver(suppressNativeNavActive);
896
- navObserver.observe(document.body, { subtree: true, attributes: true, attributeFilter: ["active"] });
897
- }
898
-
899
- function startSync() {
900
- if (syncTimer === null) syncTimer = setInterval(keepPanelInSync, 300);
901
- }
902
-
903
- function stopSync() {
904
- if (syncTimer === null) return;
905
- clearInterval(syncTimer);
906
- 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);
907
1160
  }
908
1161
 
909
1162
  function openPanel() {
910
- panelRequested = true;
911
- startSync();
912
1163
  const content = document.querySelector(".lol-settings-content");
913
- if (!content) return;
914
-
915
- const options = content.querySelector(".lol-settings-options");
916
- if (options) options.style.display = "none";
917
-
918
- const existing = document.getElementById(PANEL_ID);
919
- if (existing) existing.remove();
920
- content.append(buildPanel());
921
-
922
- const ownNav = document.getElementById(NAV_ID);
923
- if (!nativeActiveBeforeOpen) {
924
- 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() };
925
1170
  }
926
- setNavActive(ownNav);
927
- const currentTitle = document.querySelector(".lol-settings-title-current");
928
- if (currentTitle) currentTitle.textContent = TAB_TITLE.toUpperCase();
1171
+ syncPanel();
929
1172
  }
930
1173
 
931
- function closePanel() {
932
- panelRequested = false;
933
- const panel = document.getElementById(PANEL_ID);
1174
+ function syncPanel() {
934
1175
  if (!panel) return;
935
- panel.remove();
936
- const options = document.querySelector(".lol-settings-content .lol-settings-options");
937
- if (options) options.style.display = "";
938
- document.getElementById(NAV_ID)?.removeAttribute("active");
939
- if (nativeActiveBeforeOpen?.isConnected) nativeActiveBeforeOpen.setAttribute("active", "true");
940
- nativeActiveBeforeOpen = null;
941
- }
942
-
943
- function keepPanelInSync() {
944
- const content = document.querySelector(".lol-settings-content");
945
- if (!content) {
946
- panelRequested = false;
947
- stopSync();
948
- document.getElementById(NAV_ID)?.removeAttribute("active");
949
- return;
950
- }
951
- if (!panelRequested) {
952
- const staleNav = document.getElementById(NAV_ID);
953
- if (staleNav && staleNav.hasAttribute("active")) staleNav.removeAttribute("active");
1176
+ const { content, container } = panel;
1177
+ if (!content.isConnected) {
1178
+ closePanel();
954
1179
  return;
955
1180
  }
956
-
957
- const panel = document.getElementById(PANEL_ID);
958
- if (!panel || panel.parentElement !== content) {
959
- openPanel();
960
- 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);
961
1186
  }
1187
+ const nav = ownNav();
1188
+ panel.nav = nav;
1189
+ if (nav && nav.getAttribute("active") !== "true") nav.setAttribute("active", "true");
1190
+ }
962
1191
 
963
- const options = content.querySelector(".lol-settings-options");
964
- if (options && options.style.display !== "none") options.style.display = "none";
965
-
966
- const nav = document.getElementById(NAV_ID);
967
- 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
+ }
968
1205
 
969
- const title = document.querySelector(".lol-settings-title-current");
970
- 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"] });
971
1212
  }
972
1213
 
973
1214
  function ensureSettingsTab(titleNode) {
974
- startSync();
975
- if (document.getElementById(NAV_ID)) return;
976
-
1215
+ if (ownNav()) return;
977
1216
  const scope = titleNode.closest("lol-uikit-scrollable") ?? titleNode.parentElement;
978
- const firstTitle = scope?.querySelector(".lol-settings-nav-title");
1217
+ const firstTitle = scope?.querySelector(".lol-settings-nav-title:not([data-perfectionist])");
979
1218
  if (!firstTitle) return;
980
1219
 
981
- const groupTitle = el("div", "lol-settings-nav-title", "Plugins");
1220
+ const groupTitle = el("div", "lol-settings-nav-title", GROUP_TITLE);
982
1221
  groupTitle.dataset.perfectionist = "title";
983
1222
 
984
1223
  const item = document.createElement("lol-uikit-navigation-item");
@@ -990,50 +1229,47 @@ function ensureSettingsTab(titleNode) {
990
1229
  const bar = document.createElement("lol-uikit-navigation-bar");
991
1230
  bar.setAttribute("direction", "down");
992
1231
  bar.setAttribute("type", "tabbed");
1232
+ bar.setAttribute("selectedindex", "-1");
993
1233
  bar.dataset.perfectionist = "bar";
994
1234
  bar.append(item);
995
1235
 
996
1236
  firstTitle.before(groupTitle, bar);
1237
+ keepOwnNavQuiet(item);
1238
+ if (!panel) item.removeAttribute("active");
997
1239
  }
998
1240
 
999
1241
  function isDoneButton(target) {
1242
+ if (target.closest(".lol-settings-close-button")) return true;
1000
1243
  const button = target.closest("button, lol-uikit-flat-button, [role=button]");
1001
- 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();
1002
1265
  }
1003
1266
 
1004
1267
  function installSettingsTab() {
1005
1268
  addStyle(STYLE_ID, STYLE_CSS);
1006
- watchNavActive();
1007
-
1008
- document.addEventListener(
1009
- "click",
1010
- (event) => {
1011
- const target = event.target;
1012
- if (!(target instanceof Element)) return;
1013
- if (needsReload() && isDoneButton(target)) {
1014
- pendingReload = false;
1015
- unloadPending.clear();
1016
- reloadClientNow();
1017
- return;
1018
- }
1019
- if (target.closest(`#${NAV_ID}`)) {
1020
- event.stopPropagation();
1021
- openPanel();
1022
- return;
1023
- }
1024
- const nativeNav = target.closest(".lol-settings-nav");
1025
- if (nativeNav && document.getElementById(PANEL_ID)) {
1026
- closePanel();
1027
- const label = nativeNav.textContent.trim();
1028
- setTimeout(() => {
1029
- const title = document.querySelector(".lol-settings-title-current");
1030
- if (title && label && !document.getElementById(PANEL_ID)) title.textContent = label;
1031
- }, 0);
1032
- }
1033
- },
1034
- true,
1035
- );
1036
-
1269
+ document.addEventListener("click", onSettingsClick, true);
1270
+ onFrame(() => {
1271
+ if (panel) syncPanel();
1272
+ });
1037
1273
  watch(".lol-settings-nav-title", ensureSettingsTab);
1038
1274
  }
1039
1275
 
@@ -1051,10 +1287,12 @@ const utils = {
1051
1287
  uninstallModule,
1052
1288
  watch,
1053
1289
  waitFor,
1290
+ onFrame,
1054
1291
  addStyle,
1055
1292
  lcu: { get: lcuGet, send: lcuSend },
1056
1293
  subscribe,
1057
1294
  whenRcpReady,
1295
+ transformBinding,
1058
1296
  };
1059
1297
 
1060
1298
  export async function init(context, coordsProvided) {
@@ -1076,4 +1314,4 @@ export async function init(context, coordsProvided) {
1076
1314
  } catch (error) {
1077
1315
  logError("startup failed.", error);
1078
1316
  }
1079
- }
1317
+ }