perfectionist-plugins 1.8.0 → 2.0.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.
@@ -1,10 +1,11 @@
1
- export const version = "2.3.2";
1
+ export const version = "2.4.0";
2
2
 
3
3
  const PLUGIN_ID = "hide-unowned-skins";
4
4
  const STYLE_ID = "perfectionist-hide-unowned-skins-style";
5
5
  const HIDDEN_CLASS = "perf-unowned-skin-hidden";
6
6
  const SKIN_ITEM_SELECTOR = ".skin-selection-item";
7
7
  const DOT_SELECTOR = ".skin-selection-indicator-selector";
8
+ const SELECTED_CLASS = "skin-selection-item-selected";
8
9
  const SESSION_URI = "/lol-champ-select/v1/session";
9
10
  const SELECTION_URI = "/lol-champ-select/v1/session/my-selection";
10
11
  const CAROUSEL_URI = "/lol-champ-select/v1/skin-carousel-skins";
@@ -25,7 +26,7 @@ const OBSERVER_OPTIONS = {
25
26
  childList: true,
26
27
  subtree: true,
27
28
  attributes: true,
28
- attributeFilter: ["class", "style"],
29
+ attributeFilter: ["class", "style", "src"],
29
30
  };
30
31
 
31
32
  let filterActive = true;
@@ -160,7 +161,14 @@ function installFetchFilter() {
160
161
  if (!rawFetch) rawFetch = original;
161
162
 
162
163
  const wrapped = function (input, init) {
163
- const url = typeof input === "string" ? input : (input && input.url) || "";
164
+ const url =
165
+ typeof input === "string"
166
+ ? input
167
+ : typeof input?.url === "string"
168
+ ? input.url
169
+ : typeof input?.href === "string"
170
+ ? input.href
171
+ : "";
164
172
  const promise = original.call(this, input, init);
165
173
  if (!filterActive || url.split("?")[0].indexOf(CAROUSEL_URI) === -1) return promise;
166
174
  return promise.then((response) => {
@@ -263,6 +271,7 @@ let allSkinIds = null;
263
271
  let currentChampionId = null;
264
272
  let carouselOrder = [];
265
273
  let selectedSkinId = null;
274
+ let lastServerSkinId = null;
266
275
  let failure = null;
267
276
  let requestToken = 0;
268
277
  let refreshTimer = null;
@@ -273,16 +282,21 @@ let sessionMisses = 0;
273
282
  let championMisses = 0;
274
283
  let renderObserver = null;
275
284
  const repackOriginals = new Map();
285
+ const lastAppliedLeft = new Map();
276
286
  const skinSuffixCache = new Map();
277
287
  let repackTimer = null;
278
288
  let removeStyle = null;
289
+ let lastSelectedDomIndex = -1;
290
+ let navFixAt = 0;
291
+ let lastSkipSignature = null;
292
+ const widthCache = new Map();
279
293
 
280
294
  function logError(scope, error) {
281
295
  console.error(`[Perfectionist] Hide Unowned Skins: ${scope}`, error);
282
296
  }
283
297
 
284
298
  function skinSuffixOf(text) {
285
- const numbered = text.match(/Skins\/Skin(\d+)\//i) ?? text.match(/Skin(\d+)\//);
299
+ const numbered = text.match(/Skins\/Skin(\d+)\//i) ?? text.match(/skin(\d+)\//i);
286
300
  if (numbered) return Number(numbered[1]);
287
301
  return /Skins\/Base\//i.test(text) ? 0 : null;
288
302
  }
@@ -311,7 +325,49 @@ function hideTargetFor(item) {
311
325
  return target;
312
326
  }
313
327
 
328
+ function pruneRepack() {
329
+ for (const item of [...repackOriginals.keys()]) {
330
+ if (item.isConnected) continue;
331
+ repackOriginals.delete(item);
332
+ lastAppliedLeft.delete(item);
333
+ }
334
+ }
335
+
336
+ function guardSelection(items) {
337
+ const selectedIndex = items.findIndex((item) => item.classList.contains(SELECTED_CLASS));
338
+ if (selectedIndex < 0) return false;
339
+ const previous = lastSelectedDomIndex;
340
+ lastSelectedDomIndex = selectedIndex;
341
+ const selected = items[selectedIndex];
342
+ const hidden = selected.classList.contains(HIDDEN_CLASS) || Boolean(selected.parentElement?.classList.contains(HIDDEN_CLASS));
343
+ if (!hidden) {
344
+ lastSkipSignature = null;
345
+ return false;
346
+ }
347
+
348
+ const visible = items.map((item, index) => (item.classList.contains(HIDDEN_CLASS) ? -1 : index)).filter((index) => index >= 0);
349
+ if (visible.length === 0) return false;
350
+
351
+ const forward = previous < 0 ? true : selectedIndex >= previous;
352
+ const target = forward
353
+ ? (visible.find((index) => index > selectedIndex) ?? visible[0])
354
+ : ([...visible].reverse().find((index) => index < selectedIndex) ?? visible[visible.length - 1]);
355
+ const skinId = readSkinId(items[target]);
356
+ if (skinId === null) return false;
357
+
358
+ const signature = `${selectedIndex}:${skinId}`;
359
+ if (lastSkipSignature === signature) return true;
360
+ const now = Date.now();
361
+ if (now < navFixAt) return true;
362
+ navFixAt = now + 150;
363
+ lastSkipSignature = signature;
364
+ lastSelectedDomIndex = target;
365
+ selectSkin(skinId).catch((error) => logError("could not skip the unowned skin.", error));
366
+ return true;
367
+ }
368
+
314
369
  function repackVisibleItems(items) {
370
+ pruneRepack();
315
371
  const visible = items.filter((item) => !item.classList.contains(HIDDEN_CLASS));
316
372
  if (visible.length < 2) {
317
373
  restoreRepack();
@@ -326,7 +382,14 @@ function repackVisibleItems(items) {
326
382
  if (!anchor) anchor = visible[Math.floor(visible.length / 2)];
327
383
  const anchorDomIndex = items.indexOf(anchor);
328
384
 
329
- const widthOf = (item) => item.getBoundingClientRect().width || (item === anchor ? 117 : 73);
385
+ const widthOf = (item) => {
386
+ const measured = item.getBoundingClientRect().width;
387
+ if (measured > 0) {
388
+ widthCache.set(item, measured);
389
+ return measured;
390
+ }
391
+ return widthCache.get(item) ?? (item === anchor ? 117 : 73);
392
+ };
330
393
  const toPx = (item) => {
331
394
  const raw = item.style.left || "0";
332
395
  if (raw.endsWith("px")) return parseFloat(raw);
@@ -340,15 +403,17 @@ function repackVisibleItems(items) {
340
403
 
341
404
  const setLeft = (item, x) => {
342
405
  const want = `${x}px`;
343
- if (item.style.getPropertyValue("left") !== want || item.style.getPropertyPriority("left") !== "important") {
344
- if (!repackOriginals.has(item)) {
345
- repackOriginals.set(item, {
346
- value: item.style.getPropertyValue("left"),
347
- priority: item.style.getPropertyPriority("left"),
348
- });
349
- }
406
+ const current = item.style.getPropertyValue("left");
407
+ const priority = item.style.getPropertyPriority("left");
408
+ const last = lastAppliedLeft.get(item);
409
+ const clientMoved = last !== undefined && (current !== last || priority !== "important");
410
+ if (clientMoved || !repackOriginals.has(item)) {
411
+ repackOriginals.set(item, { value: current, priority });
412
+ }
413
+ if (current !== want || priority !== "important") {
350
414
  item.style.setProperty("left", want, "important");
351
415
  }
416
+ lastAppliedLeft.set(item, want);
352
417
  };
353
418
 
354
419
  items.forEach((item, index) => {
@@ -367,12 +432,16 @@ function repackVisibleItems(items) {
367
432
  }
368
433
 
369
434
  function restoreRepack() {
370
- if (repackOriginals.size === 0) return;
371
435
  for (const [item, original] of repackOriginals) {
436
+ if (!item.isConnected) continue;
437
+ const current = item.style.getPropertyValue("left");
438
+ const last = lastAppliedLeft.get(item);
439
+ if (last !== undefined && (current !== last || item.style.getPropertyPriority("left") !== "important")) continue;
372
440
  if (original.value) item.style.setProperty("left", original.value, original.priority || undefined);
373
441
  else item.style.removeProperty("left");
374
442
  }
375
443
  repackOriginals.clear();
444
+ lastAppliedLeft.clear();
376
445
  }
377
446
 
378
447
  function applyAll() {
@@ -401,8 +470,10 @@ function applyAll() {
401
470
  else if (dotsHidden === 0 && dotList.hasAttribute("data-perf-dots")) dotList.removeAttribute("data-perf-dots");
402
471
  }
403
472
 
404
- if (ownedSkinIds !== null && hidden > 0) scheduleRepack();
405
- else if (hidden === 0) restoreRepack();
473
+ if (ownedSkinIds !== null && hidden > 0) {
474
+ if (guardSelection(items)) runRepack();
475
+ else scheduleRepack();
476
+ } else if (hidden === 0) restoreRepack();
406
477
  }
407
478
 
408
479
  function scheduleRepack() {
@@ -456,8 +527,18 @@ async function loadCarouselOrder() {
456
527
  try {
457
528
  let skins;
458
529
  if (rawFetch) {
459
- const response = await rawFetch(CAROUSEL_URI, { headers: { Accept: "application/json" } });
460
- skins = response.ok ? await response.json() : null;
530
+ let timer = null;
531
+ try {
532
+ const response = await Promise.race([
533
+ rawFetch(CAROUSEL_URI, { headers: { Accept: "application/json" } }),
534
+ new Promise((_, reject) => {
535
+ timer = setTimeout(() => reject(new Error("carousel request timed out")), 8000);
536
+ }),
537
+ ]);
538
+ skins = response.ok ? await response.json() : null;
539
+ } finally {
540
+ clearTimeout(timer);
541
+ }
461
542
  } else {
462
543
  skins = await api.lcu.get(CAROUSEL_URI);
463
544
  }
@@ -478,9 +559,13 @@ function nextOwnedSkin(owned, fromId, direction) {
478
559
  async function selectSkin(skinId) {
479
560
  selectedSkinId = skinId;
480
561
  selectInFlight = true;
562
+ const guard = setTimeout(() => {
563
+ selectInFlight = false;
564
+ }, 8000);
481
565
  try {
482
566
  await api.lcu.send("PATCH", SELECTION_URI, { selectedSkinId: skinId });
483
567
  } finally {
568
+ clearTimeout(guard);
484
569
  selectInFlight = false;
485
570
  }
486
571
  }
@@ -548,15 +633,21 @@ function updateForSession(session, selection) {
548
633
 
549
634
  const knownUnownedSkin = allSkinIds !== null && allSkinIds.has(serverSkinId) && !ownedSkinIds.has(serverSkinId);
550
635
  if (!selectInFlight && knownUnownedSkin) {
551
- const fallback = nextOwnedSkin(ownedSkinIds, 0, 1);
636
+ const direction = lastServerSkinId !== null && serverSkinId !== null && serverSkinId < lastServerSkinId ? -1 : 1;
637
+ const fallback = nextOwnedSkin(ownedSkinIds, serverSkinId ?? 0, direction) ?? nextOwnedSkin(ownedSkinIds, 0, 1);
552
638
  if (fallback !== null) {
553
639
  selectSkin(fallback).catch((error) => logError("could not correct the selected skin.", error));
554
640
  }
555
641
  }
642
+ if (!selectInFlight && belongsToChampion && ownedSkinIds.has(serverSkinId)) lastServerSkinId = serverSkinId;
556
643
 
557
644
  applyAllGuarded();
558
645
  }
559
646
 
647
+ function onWindowResize() {
648
+ scheduleRepack();
649
+ }
650
+
560
651
  function scheduleRefresh() {
561
652
  clearTimeout(refreshTimer);
562
653
  refreshTimer = setTimeout(pollSession, REFRESH_DEBOUNCE_MS);
@@ -596,6 +687,7 @@ function onEnable(utils) {
596
687
  removeStyle = api.addStyle(STYLE_ID, CSS);
597
688
 
598
689
  renderObserver = new MutationObserver(() => {
690
+ ensureSourceFilter();
599
691
  const item = document.querySelector(SKIN_ITEM_SELECTOR);
600
692
  if (!item && !document.querySelector(DOT_SELECTOR)) return;
601
693
  applyAllGuarded();
@@ -603,11 +695,13 @@ function onEnable(utils) {
603
695
  });
604
696
  renderObserver.observe(document.documentElement ?? document, OBSERVER_OPTIONS);
605
697
 
698
+ window.addEventListener("resize", onWindowResize);
606
699
  unsubscribe = api.subscribe(SESSION_URI, onSessionPush);
607
700
  pollSession();
608
701
  }
609
702
 
610
703
  function onDisable() {
704
+ window.removeEventListener("resize", onWindowResize);
611
705
  unsubscribe?.();
612
706
  unsubscribe = null;
613
707
  clearTimeout(refreshTimer);
package/package.json CHANGED
@@ -1,34 +1,37 @@
1
- {
2
- "name": "perfectionist-plugins",
3
- "version": "1.8.0",
4
- "description": "Small plugins that fix the annoying parts of League of Legends champ select. Built for Pengu Loader.",
5
- "type": "module",
6
- "main": "perfectionist-utils.js",
7
- "exports": {
8
- ".": "./perfectionist-utils.js",
9
- "./perfectionist-utils.js": "./perfectionist-utils.js",
10
- "./ban-blocker.js": "./ban-blocker.js",
11
- "./hide-unowned-skins.js": "./hide-unowned-skins.js",
12
- "./unlock-profile-skins.js": "./unlock-profile-skins.js",
13
- "./auto-claimer.js": "./auto-claimer.js",
14
- "./auto-read.js": "./auto-read.js"
15
- },
16
- "files": [
17
- "perfectionist-utils.js",
18
- "ban-blocker.js",
19
- "hide-unowned-skins.js",
20
- "unlock-profile-skins.js",
21
- "auto-claimer.js",
22
- "auto-read.js"
23
- ],
24
- "repository": {
25
- "type": "git",
26
- "url": "git+https://github.com/SrKing-9812/Perfectionist-Plugins.git"
27
- },
28
- "keywords": [
29
- "pengu-loader",
30
- "league-of-legends",
31
- "plugin"
32
- ],
33
- "author": "Sr. King"
34
- }
1
+ {
2
+ "name": "perfectionist-plugins",
3
+ "version": "2.0.0",
4
+ "description": "Small plugins that fix the annoying parts of League of Legends champ select. Built for Pengu Loader.",
5
+ "type": "module",
6
+ "main": "perfectionist-utils.js",
7
+ "exports": {
8
+ ".": "./perfectionist-utils.js",
9
+ "./perfectionist-utils.js": "./perfectionist-utils.js",
10
+ "./ban-blocker.js": "./ban-blocker.js",
11
+ "./hide-unowned-skins.js": "./hide-unowned-skins.js",
12
+ "./unlock-profile-skins.js": "./unlock-profile-skins.js",
13
+ "./auto-claimer.js": "./auto-claimer.js",
14
+ "./auto-read.js": "./auto-read.js",
15
+ "./better-client.js": "./better-client.js"
16
+ },
17
+ "files": [
18
+ "perfectionist-utils.js",
19
+ "ban-blocker.js",
20
+ "hide-unowned-skins.js",
21
+ "unlock-profile-skins.js",
22
+ "auto-claimer.js",
23
+ "auto-read.js",
24
+ "better-client.js",
25
+ "tools/betterclient-monitor.js"
26
+ ],
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/SrKing-9812/Perfectionist-Plugins.git"
30
+ },
31
+ "keywords": [
32
+ "pengu-loader",
33
+ "league-of-legends",
34
+ "plugin"
35
+ ],
36
+ "author": "Sr. King"
37
+ }
@@ -6,7 +6,7 @@ const PANEL_ID = "perfectionist-plugins-panel";
6
6
  const STYLE_ID = "perfectionist-plugins-style";
7
7
  const TAB_TITLE = "Perfectionism";
8
8
 
9
- export const version = "1.7.0";
9
+ export const version = "2.0.0";
10
10
 
11
11
  const PKG = "perfectionist-plugins";
12
12
  const CDN = "https://cdn.jsdelivr.net/npm";
@@ -22,6 +22,13 @@ const BUNDLED_MODULES = [
22
22
  description: "Shared core for the Perfectionism plugins.",
23
23
  core: true,
24
24
  },
25
+ {
26
+ id: "better-client",
27
+ file: "better-client.js",
28
+ name: "BetterClient",
29
+ version: "1.2.0",
30
+ description: "Debloats the client: blocks telemetry, merges duplicate requests, caches immutable data, decodes images asynchronously and fixes several bugs.",
31
+ },
25
32
  {
26
33
  id: "ban-blocker",
27
34
  file: "ban-blocker.js",
@@ -29,13 +36,6 @@ const BUNDLED_MODULES = [
29
36
  version: "3.6.0",
30
37
  description: "During bans, blocking a teammate instantly claims the champion they hover to ban.",
31
38
  },
32
- {
33
- id: "hide-unowned-skins",
34
- file: "hide-unowned-skins.js",
35
- name: "Hide Unowned Skins",
36
- version: "2.3.2",
37
- description: "In champion select, only shows the skins you own for the picked champion.",
38
- },
39
39
  {
40
40
  id: "unlock-profile-skins",
41
41
  file: "unlock-profile-skins.js",
@@ -43,6 +43,13 @@ const BUNDLED_MODULES = [
43
43
  version: "1.2.1",
44
44
  description: "Unlocks every skin in the Set profile background window.",
45
45
  },
46
+ {
47
+ id: "hide-unowned-skins",
48
+ file: "hide-unowned-skins.js",
49
+ name: "Hide Unowned Skins",
50
+ version: "2.4.0",
51
+ description: "In champion select, only shows the skins you own for the picked champion.",
52
+ },
46
53
  {
47
54
  id: "auto-claimer",
48
55
  file: "auto-claimer.js",
@@ -54,14 +61,14 @@ const BUNDLED_MODULES = [
54
61
  id: "auto-read",
55
62
  file: "auto-read.js",
56
63
  name: "Auto Read",
57
- version: "1.1.0",
58
- description: "Automatically marks quests, loot, Clash and client notices as read so no badge or dot stays lit.",
64
+ version: "1.2.0",
65
+ description: "Automatically marks quests, loot, Clash, TFT and client notices as read so no badge or dot stays lit.",
59
66
  },
60
67
  ];
61
68
 
62
69
  const STYLE_CSS = `
63
- #${PANEL_ID} { 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; }
64
- #${PANEL_ID} > lol-uikit-scrollable { display: block; height: 100%; }
70
+ #${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; }
71
+ #${PANEL_ID} > lol-uikit-scrollable { display: block; flex: 1 1 auto; min-height: 0; }
65
72
  #${PANEL_ID} .perf-card { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px;
66
73
  padding: 16px 18px; margin-bottom: 12px; box-sizing: border-box;
67
74
  background: #010a13; border: 1px solid #785a28; }
@@ -121,6 +128,21 @@ const STYLE_CSS = `
121
128
  #${PANEL_ID} .perf-chip--on { color: #010a13; background: #c89b3c; border-color: #c89b3c; }
122
129
  #${PANEL_ID} .perf-chip--on:hover { background: #f0e6d2; }
123
130
 
131
+ #${PANEL_ID} .perf-tools { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; margin-bottom: 14px; }
132
+ #${PANEL_ID} .perf-search { flex: 1 1 auto; min-width: 0; box-sizing: border-box;
133
+ font-family: "LoL Body", Arial, "Helvetica Neue", Helvetica, sans-serif; font-size: 12px; letter-spacing: 0.3px;
134
+ color: #f0e6d2; background: #0a1428; border: 1px solid #785a28; padding: 9px 12px; outline: none; }
135
+ #${PANEL_ID} .perf-search:focus { border-color: #c89b3c; }
136
+ #${PANEL_ID} .perf-search::placeholder { color: #a09b8c; }
137
+ #${PANEL_ID} .perf-master { flex: 0 0 auto; white-space: nowrap; }
138
+ #${PANEL_ID} .perf-card--locked .perf-opts { pointer-events: none; }
139
+ #${PANEL_ID} .perf-card--locked .perf-actions > .perf-toggle { pointer-events: none; }
140
+ #${PANEL_ID} .perf-card--locked .perf-caret { opacity: 0.45; }
141
+ #${PANEL_ID} .perf-card--locked .perf-chips-label { opacity: 0.45; }
142
+ #${PANEL_ID} .perf-chip:disabled { opacity: 0.45; cursor: default; }
143
+ #${PANEL_ID} .perf-chip:disabled:hover { color: #a09b8c; border-color: #785a28; background: #0a1428; }
144
+ #${PANEL_ID} .perf-chip--on:disabled:hover { color: #010a13; border-color: #c89b3c; background: #c89b3c; }
145
+
124
146
  .perf-update { position: fixed; inset: 0; z-index: 100000; display: flex; align-items: center;
125
147
  justify-content: center; background: rgba(1, 10, 19, 0.72); }
126
148
  .perf-update-box { width: 430px; box-sizing: border-box; padding: 22px 26px;
@@ -145,9 +167,10 @@ let coords = null;
145
167
  const resolved = new Map();
146
168
  let availableModules = BUNDLED_MODULES;
147
169
  let pendingReload = false;
170
+ let searchQuery = "";
148
171
  let runningVersion = version;
149
172
 
150
- let state = { installed: {}, enabled: {}, options: {} };
173
+ let state = { installed: {}, enabled: {}, options: {}, masterLocked: false };
151
174
 
152
175
  async function loadState() {
153
176
  let saved = null;
@@ -160,7 +183,7 @@ async function loadState() {
160
183
  saved = null;
161
184
  }
162
185
  if (saved?.installed && typeof saved.installed === "object") {
163
- state = { installed: saved.installed, enabled: saved.enabled ?? {}, options: saved.options ?? {} };
186
+ state = { installed: saved.installed, enabled: saved.enabled ?? {}, options: saved.options ?? {}, masterLocked: saved.masterLocked === true };
164
187
  return;
165
188
  }
166
189
  saveState();
@@ -172,6 +195,7 @@ function saveState() {
172
195
  installed: state.installed,
173
196
  enabled: state.enabled,
174
197
  options: state.options,
198
+ masterLocked: state.masterLocked === true,
175
199
  };
176
200
  try {
177
201
  localStorage.setItem(STATE_KEY, JSON.stringify(record));
@@ -524,16 +548,23 @@ function addStyle(name, css) {
524
548
  return () => element.remove();
525
549
  }
526
550
 
551
+ const LCU_TIMEOUT_MS = 10000;
552
+
527
553
  function lcuGet(path, { ttl = 0 } = {}) {
528
554
  const now = Date.now();
529
555
  const cached = requestCache.get(path);
530
556
  if (cached && (cached.pending || cached.expires > now)) return cached.promise;
531
557
 
532
558
  const entry = { pending: true, expires: 0, promise: null };
533
- entry.promise = fetch(path, { headers: { Accept: "application/json" } }).then(async (response) => {
559
+ const request = fetch(path, { headers: { Accept: "application/json" } }).then(async (response) => {
534
560
  if (!response.ok) throw new Error(`LCU ${path} responded ${response.status}`);
535
561
  return response.status === 204 ? null : response.json();
536
562
  });
563
+ let timer = null;
564
+ const timeout = new Promise((_, reject) => {
565
+ timer = setTimeout(() => reject(new Error(`LCU ${path} timed out`)), LCU_TIMEOUT_MS);
566
+ });
567
+ entry.promise = Promise.race([request, timeout]).finally(() => clearTimeout(timer));
537
568
  requestCache.set(path, entry);
538
569
  entry.promise.then(
539
570
  () => {
@@ -605,7 +636,7 @@ function buildOptionRow(mod, option, disabled, onChange) {
605
636
  return row;
606
637
  }
607
638
 
608
- function buildChips(mod, option) {
639
+ function buildChips(mod, option, locked) {
609
640
  const block = el("div", "perf-chip-block");
610
641
  block.title = option.description ?? "";
611
642
  if (option.label) block.append(el("div", "perf-chips-label", option.label));
@@ -615,6 +646,7 @@ function buildChips(mod, option) {
615
646
  const chip = el("button", "perf-chip", item.label);
616
647
  chip.type = "button";
617
648
  chip.title = item.description ?? "";
649
+ chip.disabled = Boolean(locked);
618
650
  if (getOption(mod.id, key)) chip.classList.add("perf-chip--on");
619
651
  chip.addEventListener("click", () => {
620
652
  setOption(mod.id, key, !getOption(mod.id, key), false);
@@ -626,17 +658,17 @@ function buildChips(mod, option) {
626
658
  return block;
627
659
  }
628
660
 
629
- function buildOptions(mod) {
661
+ function buildOptions(mod, locked) {
630
662
  const options = registry.get(mod.id)?.options ?? [];
631
663
  if (options.length === 0) return null;
632
664
  const wrap = el("div", "perf-opts");
633
665
  for (const option of options) {
634
666
  if (option.type === "chips") {
635
- wrap.append(buildChips(mod, option));
667
+ wrap.append(buildChips(mod, option, locked));
636
668
  continue;
637
669
  }
638
670
  if (!option.children?.length) {
639
- wrap.append(buildOptionRow(mod, option));
671
+ wrap.append(buildOptionRow(mod, option, Boolean(locked)));
640
672
  continue;
641
673
  }
642
674
  const group = el("div", "perf-opt-group");
@@ -647,12 +679,12 @@ function buildOptions(mod) {
647
679
  const kids = el("div", "perf-opt-children");
648
680
  const childInputs = [];
649
681
  for (const child of option.children) {
650
- const childRow = buildOptionRow(mod, child, !getOption(mod.id, option.key));
682
+ const childRow = buildOptionRow(mod, child, !getOption(mod.id, option.key) || Boolean(locked));
651
683
  childInputs.push(childRow.querySelector("input"));
652
684
  kids.append(childRow);
653
685
  }
654
- const master = buildOptionRow(mod, option, false, (checked) => {
655
- for (const input of childInputs) input.disabled = !checked;
686
+ const master = buildOptionRow(mod, option, Boolean(locked), (checked) => {
687
+ for (const input of childInputs) input.disabled = !checked || Boolean(locked);
656
688
  setOption(mod.id, option.key, checked, false);
657
689
  });
658
690
  const groupKey = `${mod.id}:${option.key}`;
@@ -674,12 +706,13 @@ function buildOptions(mod) {
674
706
  return wrap;
675
707
  }
676
708
 
677
- function buildToggle(mod) {
709
+ function buildToggle(mod, locked) {
678
710
  const label = el("label", "perf-toggle");
679
711
  label.title = `Enable ${mod.name}`;
680
712
  const input = el("input");
681
713
  input.type = "checkbox";
682
714
  input.checked = isPluginEnabled(mod.id);
715
+ input.disabled = Boolean(locked);
683
716
  input.setAttribute("aria-label", `Enable ${mod.name}`);
684
717
  input.addEventListener("change", () => setPluginEnabled(mod.id, input.checked));
685
718
  label.append(input, el("span", "perf-box"));
@@ -711,20 +744,87 @@ function buildUninstallButton(mod) {
711
744
  return button;
712
745
  }
713
746
 
714
- function buildCard(mod) {
747
+ function installedModules() {
748
+ return getAvailableModules().filter(isInstalled);
749
+ }
750
+
751
+ function masterLocked() {
752
+ return installedModules().length > 0 && state.masterLocked === true;
753
+ }
754
+
755
+ function disableAllPlugins() {
756
+ state.masterLocked = true;
757
+ saveState();
758
+ refreshPanel();
759
+ }
760
+
761
+ function enableAllPlugins() {
762
+ state.masterLocked = false;
763
+ saveState();
764
+ refreshPanel();
765
+ }
766
+
767
+ function applySearchFilter(panel = document.getElementById(PANEL_ID)) {
768
+ if (!panel) return;
769
+ let shown = 0;
770
+ for (const card of panel.querySelectorAll(".perf-card")) {
771
+ const match = !searchQuery || (card.dataset.search ?? "").includes(searchQuery);
772
+ card.style.display = match ? "" : "none";
773
+ if (match) shown += 1;
774
+ }
775
+ const scroller = panel.querySelector("lol-uikit-scrollable");
776
+ let empty = panel.querySelector(".perf-no-match");
777
+ if (shown === 0 && getAvailableModules().length > 0) {
778
+ if (!empty && scroller) {
779
+ empty = el("div", "perf-empty perf-no-match", "No plugins match your search.");
780
+ scroller.append(empty);
781
+ }
782
+ } else if (empty) {
783
+ empty.remove();
784
+ }
785
+ }
786
+
787
+ function buildTools() {
788
+ const tools = el("div", "perf-tools");
789
+
790
+ const search = el("input", "perf-search");
791
+ search.type = "search";
792
+ search.placeholder = "Search plugins...";
793
+ search.value = searchQuery;
794
+ search.setAttribute("aria-label", "Search plugins");
795
+ search.addEventListener("input", () => {
796
+ searchQuery = search.value.trim().toLowerCase();
797
+ applySearchFilter();
798
+ });
799
+
800
+ const master = el("button", "perf-btn perf-master", masterLocked() ? "Enable All" : "Disable All");
801
+ master.type = "button";
802
+ master.disabled = installedModules().length === 0;
803
+ master.addEventListener("click", () => {
804
+ if (masterLocked()) enableAllPlugins();
805
+ else disableAllPlugins();
806
+ });
807
+
808
+ tools.append(search, master);
809
+ return tools;
810
+ }
811
+
812
+ function buildCard(mod, locked) {
715
813
  const installed = isInstalled(mod);
716
814
  const card = el("div", installed ? "perf-card" : "perf-card perf-card--missing");
815
+ if (locked) card.classList.add("perf-card--locked");
816
+ card.dataset.search = `${mod.name} ${mod.description}`.toLowerCase();
717
817
 
718
818
  const info = el("div", "perf-info");
719
819
  const name = el("div", "perf-name", mod.name);
720
820
  name.append(el("span", "perf-version", `v${registry.get(mod.id)?.version ?? mod.version}`));
721
821
  info.append(name);
722
822
  if (mod.description) info.append(el("div", "perf-desc", mod.description));
723
- const optionList = buildOptions(mod);
823
+ const optionList = buildOptions(mod, locked);
724
824
  if (optionList) info.append(optionList);
725
825
 
726
826
  const actions = el("div", "perf-actions");
727
- if (installed) actions.append(buildToggle(mod));
827
+ if (installed) actions.append(buildToggle(mod, locked));
728
828
  actions.append(installed ? buildUninstallButton(mod) : buildInstallButton(mod));
729
829
 
730
830
  card.append(info, actions);
@@ -734,6 +834,8 @@ function buildCard(mod) {
734
834
  function buildPanel() {
735
835
  const panel = el("div");
736
836
  panel.id = PANEL_ID;
837
+ panel.append(buildTools());
838
+
737
839
  const scroller = el("lol-uikit-scrollable");
738
840
  panel.append(scroller);
739
841
 
@@ -742,7 +844,9 @@ function buildPanel() {
742
844
  scroller.append(el("div", "perf-empty", "No plugins are available."));
743
845
  return panel;
744
846
  }
745
- for (const mod of modules) scroller.append(buildCard(mod));
847
+ const locked = masterLocked();
848
+ for (const mod of modules) scroller.append(buildCard(mod, locked));
849
+ applySearchFilter(panel);
746
850
  return panel;
747
851
  }
748
852