perfectionist-plugins 1.6.0 → 1.9.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.
package/auto-read.js CHANGED
@@ -1,5 +1,5 @@
1
1
  const PLUGIN_ID = "auto-read";
2
- const version = "1.0.0";
2
+ const version = "1.2.0";
3
3
 
4
4
  const SWEEP_DEBOUNCE_MS = 500;
5
5
  const ITEM_COOLDOWN_MS = 10 * 60 * 1000;
@@ -9,6 +9,16 @@ const MISSIONS_URI = "/lol-missions/v1/missions";
9
9
  const MISSIONS_PLAYER_URI = "/lol-missions/v1/player";
10
10
  const LOOT_URI = "/lol-loot/v1/player-loot";
11
11
  const CLASH_FLAGS_URI = "/lol-clash/v1/simple-state-flags";
12
+ const REWARDS_GRANTS_URI = "/lol-rewards/v1/grants";
13
+ const REWARDS_VIEW_URI = "/lol-rewards/v1/grants/view";
14
+ const LOOT_NOTIF_URI = "/lol-loot/v1/player-loot-notifications";
15
+ const MASTERY_NOTIF_URI = "/lol-champion-mastery/v1/notifications";
16
+ const MASTERY_ACK_URI = "/lol-champion-mastery/v1/notifications/ack";
17
+ const HONOR_NOTICES = [
18
+ { uri: "/lol-honor-v2/v1/reward-granted", ack: "/lol-honor-v2/v1/reward-granted/ack", present: (d) => Number(d?.quantity ?? -1) >= 0 || Boolean(d?.rewardType) },
19
+ { uri: "/lol-honor-v2/v1/mutual-honor", ack: "/lol-honor-v2/v1/mutual-honor/ack", present: (d) => (d?.summoners?.length ?? 0) > 0 },
20
+ { uri: "/lol-honor-v2/v1/level-change", ack: "/lol-honor-v2/v1/level-change/ack", present: (d) => Number(d?.currentState?.level ?? -1) >= 0 },
21
+ ];
12
22
  const RANKED_NOTIF_URI = "/lol-ranked/v1/notifications";
13
23
  const EOS_NOTIF_URI = "/lol-ranked/v1/eos-notifications";
14
24
  const NOTICE_LIST_URIS = [
@@ -29,6 +39,13 @@ const CLASH_BULK_ACKS = [
29
39
  "/lol-clash/v1/eog-player-update/acknowledge",
30
40
  "/lol-clash/v1/game-end/acknowledge",
31
41
  ];
42
+ const IDENTITY_NOTICE_TYPES = ["ACHIEVEMENT_TITLE", "SUMMONER_ICON", "REGALIA_BANNER"];
43
+ const IDENTITY_ACK_URI = "/lol-inventory/v1/notification/acknowledge";
44
+ const TOKENS_PREFS_URI = "/lol-settings/v2/account/LCUPreferences/lol-customizer-tokens";
45
+ const LEVELUP_PREFS_URI = "/lol-settings/v2/account/LCUPreferences/lol-challenges-latest-level-up";
46
+ const TFT_BATTLE_PASS_URI = "/lol-tft-pass/v1/battle-pass";
47
+ const TFT_HOME_HUB_URI = "/lol-tft/v1/tft/homeHub";
48
+ const TFT_PREFS_URI = "/lol-settings/v2/account/LCUPreferences/lol-tft";
32
49
  const PIP_STYLE_ID = "auto-read-pips-style";
33
50
  const PIP_CSS = ".activity-center__tab_label_notification { display: none !important; }";
34
51
 
@@ -39,6 +56,7 @@ let sweepRunning = false;
39
56
  let sweepQueued = false;
40
57
  let unsubscribes = [];
41
58
  const cooldowns = new Map();
59
+ const markedGrants = new Set();
42
60
 
43
61
  function on(key) {
44
62
  try {
@@ -116,6 +134,49 @@ async function sweepClash() {
116
134
  }
117
135
  }
118
136
 
137
+ async function sweepRewards() {
138
+ const grants = await getJson(REWARDS_GRANTS_URI).catch(() => null);
139
+ if (Array.isArray(grants) && grants.length && ready("rewards-grants", BULK_COOLDOWN_MS)) {
140
+ const ids = [];
141
+ for (const grant of grants) {
142
+ const id = grant?.grantId ?? grant?.id;
143
+ if (typeof id !== "string" || !id || markedGrants.has(id)) continue;
144
+ ids.push(id);
145
+ }
146
+ if (ids.length) {
147
+ stamp("rewards-grants");
148
+ for (const id of ids) markedGrants.add(id);
149
+ await sendJson("PATCH", REWARDS_VIEW_URI, ids).catch(() => {
150
+ for (const id of ids) markedGrants.delete(id);
151
+ });
152
+ }
153
+ }
154
+
155
+ const notes = await getJson(LOOT_NOTIF_URI).catch(() => null);
156
+ if (Array.isArray(notes)) {
157
+ for (const note of notes) {
158
+ const id = note?.id ?? note?.lootId;
159
+ if (!id || !ready(`lootnote:${id}`, ITEM_COOLDOWN_MS)) continue;
160
+ stamp(`lootnote:${id}`);
161
+ await sendJson("POST", `${LOOT_NOTIF_URI}/${encodeURIComponent(id)}/acknowledge`).catch(() => {});
162
+ }
163
+ }
164
+
165
+ const mastery = await getJson(MASTERY_NOTIF_URI).catch(() => null);
166
+ if (Number(mastery?.championId ?? 0) > 0 && ready("mastery", ITEM_COOLDOWN_MS)) {
167
+ stamp("mastery");
168
+ await sendJson("POST", MASTERY_ACK_URI).catch(() => {});
169
+ }
170
+
171
+ for (const notice of HONOR_NOTICES) {
172
+ const data = await getJson(notice.uri).catch(() => null);
173
+ const key = `honor:${notice.uri}`;
174
+ if (!data || !notice.present(data) || !ready(key, ITEM_COOLDOWN_MS)) continue;
175
+ stamp(key);
176
+ await sendJson("POST", notice.ack).catch(() => {});
177
+ }
178
+ }
179
+
119
180
  async function sweepRanked() {
120
181
  for (const uri of [RANKED_NOTIF_URI, EOS_NOTIF_URI]) {
121
182
  const list = await getJson(uri).catch(() => null);
@@ -147,6 +208,65 @@ async function sweepNotices() {
147
208
  }
148
209
  }
149
210
 
211
+ async function sweepIdentity() {
212
+ for (const type of IDENTITY_NOTICE_TYPES) {
213
+ const list = await getJson(`/lol-inventory/v1/notifications/${type}`).catch(() => null);
214
+ if (!Array.isArray(list)) continue;
215
+ for (const note of list) {
216
+ const id = note?.id;
217
+ if (id == null || note.acknowledged === true) continue;
218
+ if (!ready(`invnote:${id}`, ITEM_COOLDOWN_MS)) continue;
219
+ stamp(`invnote:${id}`);
220
+ await sendJson("POST", IDENTITY_ACK_URI, { id }).catch(() => {});
221
+ }
222
+ }
223
+ if (!ready("tokens-visit", BULK_COOLDOWN_MS)) return;
224
+ const [tokens, levelUp] = await Promise.all([
225
+ getJson(TOKENS_PREFS_URI).catch(() => null),
226
+ getJson(LEVELUP_PREFS_URI).catch(() => null),
227
+ ]);
228
+ const lastVisit = Number(tokens?.data?.lastVisitTime ?? 0);
229
+ const lastLevelUp = Number(levelUp?.data?.lastLevelUpTime ?? 0);
230
+ if (!lastVisit || !(lastLevelUp > lastVisit)) return;
231
+ stamp("tokens-visit");
232
+ await sendJson("PUT", TOKENS_PREFS_URI, { schemaVersion: 1, data: { lastVisitTime: Date.now() } }).catch(() => {});
233
+ }
234
+
235
+ async function sweepTft() {
236
+ const bp = await getJson(TFT_BATTLE_PASS_URI).catch(() => null);
237
+ if (bp && Array.isArray(bp.milestones) && Array.isArray(bp.bonuses)) {
238
+ const list = [...bp.milestones, ...bp.bonuses];
239
+ const claimable = list.some((m) => m && m.status === "CLAIMABLE" && !m.isLocked);
240
+ const pending = list.some((m) => m && m.isClaimRequestPending);
241
+ const passId = bp?.info?.passId;
242
+ if (claimable && !pending && passId && ready("tft-claim", BULK_COOLDOWN_MS)) {
243
+ stamp("tft-claim");
244
+ await sendJson("PUT", `/lol-tft-pass/v1/pass/${encodeURIComponent(passId)}/milestone/claimAllRewards`).catch(() => {});
245
+ }
246
+ }
247
+ if (!ready("tft-promos", BULK_COOLDOWN_MS)) return;
248
+ const [hub, prefs] = await Promise.all([
249
+ getJson(TFT_HOME_HUB_URI).catch(() => null),
250
+ getJson(TFT_PREFS_URI).catch(() => null),
251
+ ]);
252
+ const store = hub?.storePromoOfferIds;
253
+ const battle = hub?.battlePassOfferIds;
254
+ const tact = hub?.tacticianPromoOfferIds;
255
+ if (!hub?.enabled || !Array.isArray(store) || !Array.isArray(battle) || !Array.isArray(tact)) return;
256
+ const target = { storeOfferIds: [battle[0], store[0]], tacticianOfferIds: tact };
257
+ const seen = prefs?.data?.seenOfferIds;
258
+ const same =
259
+ Array.isArray(seen?.storeOfferIds) &&
260
+ seen.storeOfferIds.length === target.storeOfferIds.length &&
261
+ seen.storeOfferIds.every((value, index) => value === target.storeOfferIds[index]) &&
262
+ Array.isArray(seen?.tacticianOfferIds) &&
263
+ seen.tacticianOfferIds.length === target.tacticianOfferIds.length &&
264
+ seen.tacticianOfferIds.every((value, index) => value === target.tacticianOfferIds[index]);
265
+ if (same) return;
266
+ stamp("tft-promos");
267
+ await sendJson("PATCH", TFT_PREFS_URI, { data: { seenOfferIds: target }, schemaVersion: 1 }).catch(() => {});
268
+ }
269
+
150
270
  function syncPips() {
151
271
  const wanted = active && on("pips");
152
272
  let style = document.getElementById(PIP_STYLE_ID);
@@ -176,6 +296,11 @@ async function runSweep() {
176
296
  try {
177
297
  if (on("quests")) await sweepQuests().catch(() => {});
178
298
  if (on("loot")) await sweepLootMarks().catch(() => {});
299
+ if (on("rewards")) {
300
+ await sweepRewards().catch(() => {});
301
+ await sweepIdentity().catch(() => {});
302
+ }
303
+ if (on("tft")) await sweepTft().catch(() => {});
179
304
  if (on("clash")) await sweepClash().catch(() => {});
180
305
  if (on("ranked")) await sweepRanked().catch(() => {});
181
306
  if (on("notices")) await sweepNotices().catch(() => {});
@@ -192,7 +317,7 @@ function onEnable(utils) {
192
317
  api = utils;
193
318
  active = true;
194
319
  cooldowns.clear();
195
- for (const uri of [MISSIONS_URI, LOOT_URI, CLASH_FLAGS_URI, RANKED_NOTIF_URI, EOS_NOTIF_URI, ...NOTICE_LIST_URIS]) {
320
+ for (const uri of [MISSIONS_URI, LOOT_URI, CLASH_FLAGS_URI, REWARDS_GRANTS_URI, LOOT_NOTIF_URI, MASTERY_NOTIF_URI, ...HONOR_NOTICES.map((n) => n.uri), RANKED_NOTIF_URI, EOS_NOTIF_URI, ...NOTICE_LIST_URIS, ...IDENTITY_NOTICE_TYPES.map((type) => `/lol-inventory/v1/notifications/${type}`), TOKENS_PREFS_URI, LEVELUP_PREFS_URI, TFT_BATTLE_PASS_URI, TFT_HOME_HUB_URI, TFT_PREFS_URI]) {
196
321
  try {
197
322
  const off = api.subscribe(uri, scheduleSweep);
198
323
  if (off) unsubscribes.push(off);
@@ -215,6 +340,7 @@ function onDisable() {
215
340
  }
216
341
  unsubscribes = [];
217
342
  cooldowns.clear();
343
+ markedGrants.clear();
218
344
  api = null;
219
345
  syncPips();
220
346
  }
@@ -230,7 +356,7 @@ export function init() {
230
356
  utils.registerPlugin({
231
357
  id: PLUGIN_ID,
232
358
  name: "Auto Read",
233
- description: "Automatically marks quests, loot, Clash and client notices as read so no badge or dot stays lit.",
359
+ description: "Automatically marks quests, loot, Clash, TFT and client notices as read so no badge or dot stays lit.",
234
360
  version,
235
361
  defaultEnabled: true,
236
362
  options: [
@@ -242,6 +368,8 @@ export function init() {
242
368
  items: [
243
369
  { id: "quests", label: "Quests", description: "Quest and mission badges, including the mission button counter.", default: true },
244
370
  { id: "loot", label: "Loot marks", description: "The new mark on freshly earned loot.", default: true },
371
+ { id: "rewards", label: "Rewards", description: "The dot on your avatar for new tokens, titles and banners, plus mastery, honor and loot notifications.", default: true },
372
+ { id: "tft", label: "TFT tab", description: "Claims claimable TFT pass rewards and marks TFT promo offers as seen so the TFT tab dot stays off.", default: true },
245
373
  { id: "clash", label: "Clash", description: "Clash tab flags and Clash notifications.", default: true },
246
374
  { id: "ranked", label: "Ranked", description: "Ranked, end-of-season and champion mastery notices.", default: true },
247
375
  { id: "notices", label: "Notices", description: "Patch, patcher, leaver buster, lobby, behavior and gameflow notices.", default: true },
@@ -1,4 +1,4 @@
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";
@@ -25,7 +25,7 @@ const OBSERVER_OPTIONS = {
25
25
  childList: true,
26
26
  subtree: true,
27
27
  attributes: true,
28
- attributeFilter: ["class", "style"],
28
+ attributeFilter: ["class", "style", "src"],
29
29
  };
30
30
 
31
31
  let filterActive = true;
@@ -160,7 +160,14 @@ function installFetchFilter() {
160
160
  if (!rawFetch) rawFetch = original;
161
161
 
162
162
  const wrapped = function (input, init) {
163
- const url = typeof input === "string" ? input : (input && input.url) || "";
163
+ const url =
164
+ typeof input === "string"
165
+ ? input
166
+ : typeof input?.url === "string"
167
+ ? input.url
168
+ : typeof input?.href === "string"
169
+ ? input.href
170
+ : "";
164
171
  const promise = original.call(this, input, init);
165
172
  if (!filterActive || url.split("?")[0].indexOf(CAROUSEL_URI) === -1) return promise;
166
173
  return promise.then((response) => {
@@ -273,6 +280,7 @@ let sessionMisses = 0;
273
280
  let championMisses = 0;
274
281
  let renderObserver = null;
275
282
  const repackOriginals = new Map();
283
+ const lastAppliedLeft = new Map();
276
284
  const skinSuffixCache = new Map();
277
285
  let repackTimer = null;
278
286
  let removeStyle = null;
@@ -282,7 +290,7 @@ function logError(scope, error) {
282
290
  }
283
291
 
284
292
  function skinSuffixOf(text) {
285
- const numbered = text.match(/Skins\/Skin(\d+)\//i) ?? text.match(/Skin(\d+)\//);
293
+ const numbered = text.match(/Skins\/Skin(\d+)\//i) ?? text.match(/skin(\d+)\//i);
286
294
  if (numbered) return Number(numbered[1]);
287
295
  return /Skins\/Base\//i.test(text) ? 0 : null;
288
296
  }
@@ -311,7 +319,16 @@ function hideTargetFor(item) {
311
319
  return target;
312
320
  }
313
321
 
322
+ function pruneRepack() {
323
+ for (const item of [...repackOriginals.keys()]) {
324
+ if (item.isConnected) continue;
325
+ repackOriginals.delete(item);
326
+ lastAppliedLeft.delete(item);
327
+ }
328
+ }
329
+
314
330
  function repackVisibleItems(items) {
331
+ pruneRepack();
315
332
  const visible = items.filter((item) => !item.classList.contains(HIDDEN_CLASS));
316
333
  if (visible.length < 2) {
317
334
  restoreRepack();
@@ -340,15 +357,17 @@ function repackVisibleItems(items) {
340
357
 
341
358
  const setLeft = (item, x) => {
342
359
  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
- }
360
+ const current = item.style.getPropertyValue("left");
361
+ const priority = item.style.getPropertyPriority("left");
362
+ const last = lastAppliedLeft.get(item);
363
+ const clientMoved = last !== undefined && (current !== last || priority !== "important");
364
+ if (clientMoved || !repackOriginals.has(item)) {
365
+ repackOriginals.set(item, { value: current, priority });
366
+ }
367
+ if (current !== want || priority !== "important") {
350
368
  item.style.setProperty("left", want, "important");
351
369
  }
370
+ lastAppliedLeft.set(item, want);
352
371
  };
353
372
 
354
373
  items.forEach((item, index) => {
@@ -367,12 +386,16 @@ function repackVisibleItems(items) {
367
386
  }
368
387
 
369
388
  function restoreRepack() {
370
- if (repackOriginals.size === 0) return;
371
389
  for (const [item, original] of repackOriginals) {
390
+ if (!item.isConnected) continue;
391
+ const current = item.style.getPropertyValue("left");
392
+ const last = lastAppliedLeft.get(item);
393
+ if (last !== undefined && (current !== last || item.style.getPropertyPriority("left") !== "important")) continue;
372
394
  if (original.value) item.style.setProperty("left", original.value, original.priority || undefined);
373
395
  else item.style.removeProperty("left");
374
396
  }
375
397
  repackOriginals.clear();
398
+ lastAppliedLeft.clear();
376
399
  }
377
400
 
378
401
  function applyAll() {
@@ -456,8 +479,18 @@ async function loadCarouselOrder() {
456
479
  try {
457
480
  let skins;
458
481
  if (rawFetch) {
459
- const response = await rawFetch(CAROUSEL_URI, { headers: { Accept: "application/json" } });
460
- skins = response.ok ? await response.json() : null;
482
+ let timer = null;
483
+ try {
484
+ const response = await Promise.race([
485
+ rawFetch(CAROUSEL_URI, { headers: { Accept: "application/json" } }),
486
+ new Promise((_, reject) => {
487
+ timer = setTimeout(() => reject(new Error("carousel request timed out")), 8000);
488
+ }),
489
+ ]);
490
+ skins = response.ok ? await response.json() : null;
491
+ } finally {
492
+ clearTimeout(timer);
493
+ }
461
494
  } else {
462
495
  skins = await api.lcu.get(CAROUSEL_URI);
463
496
  }
@@ -478,9 +511,13 @@ function nextOwnedSkin(owned, fromId, direction) {
478
511
  async function selectSkin(skinId) {
479
512
  selectedSkinId = skinId;
480
513
  selectInFlight = true;
514
+ const guard = setTimeout(() => {
515
+ selectInFlight = false;
516
+ }, 8000);
481
517
  try {
482
518
  await api.lcu.send("PATCH", SELECTION_URI, { selectedSkinId: skinId });
483
519
  } finally {
520
+ clearTimeout(guard);
484
521
  selectInFlight = false;
485
522
  }
486
523
  }
@@ -557,6 +594,10 @@ function updateForSession(session, selection) {
557
594
  applyAllGuarded();
558
595
  }
559
596
 
597
+ function onWindowResize() {
598
+ scheduleRepack();
599
+ }
600
+
560
601
  function scheduleRefresh() {
561
602
  clearTimeout(refreshTimer);
562
603
  refreshTimer = setTimeout(pollSession, REFRESH_DEBOUNCE_MS);
@@ -596,6 +637,7 @@ function onEnable(utils) {
596
637
  removeStyle = api.addStyle(STYLE_ID, CSS);
597
638
 
598
639
  renderObserver = new MutationObserver(() => {
640
+ ensureSourceFilter();
599
641
  const item = document.querySelector(SKIN_ITEM_SELECTOR);
600
642
  if (!item && !document.querySelector(DOT_SELECTOR)) return;
601
643
  applyAllGuarded();
@@ -603,11 +645,13 @@ function onEnable(utils) {
603
645
  });
604
646
  renderObserver.observe(document.documentElement ?? document, OBSERVER_OPTIONS);
605
647
 
648
+ window.addEventListener("resize", onWindowResize);
606
649
  unsubscribe = api.subscribe(SESSION_URI, onSessionPush);
607
650
  pollSession();
608
651
  }
609
652
 
610
653
  function onDisable() {
654
+ window.removeEventListener("resize", onWindowResize);
611
655
  unsubscribe?.();
612
656
  unsubscribe = null;
613
657
  clearTimeout(refreshTimer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perfectionist-plugins",
3
- "version": "1.6.0",
3
+ "version": "1.9.0",
4
4
  "description": "Small plugins that fix the annoying parts of League of Legends champ select. Built for Pengu Loader.",
5
5
  "type": "module",
6
6
  "main": "perfectionist-utils.js",
@@ -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.6.0";
9
+ export const version = "1.8.0";
10
10
 
11
11
  const PKG = "perfectionist-plugins";
12
12
  const CDN = "https://cdn.jsdelivr.net/npm";
@@ -33,7 +33,7 @@ const BUNDLED_MODULES = [
33
33
  id: "hide-unowned-skins",
34
34
  file: "hide-unowned-skins.js",
35
35
  name: "Hide Unowned Skins",
36
- version: "2.3.2",
36
+ version: "2.4.0",
37
37
  description: "In champion select, only shows the skins you own for the picked champion.",
38
38
  },
39
39
  {
@@ -54,14 +54,14 @@ const BUNDLED_MODULES = [
54
54
  id: "auto-read",
55
55
  file: "auto-read.js",
56
56
  name: "Auto Read",
57
- version: "1.0.0",
58
- description: "Automatically marks quests, loot, Clash and client notices as read so no badge or dot stays lit.",
57
+ version: "1.2.0",
58
+ description: "Automatically marks quests, loot, Clash, TFT and client notices as read so no badge or dot stays lit.",
59
59
  },
60
60
  ];
61
61
 
62
62
  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%; }
63
+ #${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; }
64
+ #${PANEL_ID} > lol-uikit-scrollable { display: block; flex: 1 1 auto; min-height: 0; }
65
65
  #${PANEL_ID} .perf-card { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px;
66
66
  padding: 16px 18px; margin-bottom: 12px; box-sizing: border-box;
67
67
  background: #010a13; border: 1px solid #785a28; }
@@ -121,6 +121,21 @@ const STYLE_CSS = `
121
121
  #${PANEL_ID} .perf-chip--on { color: #010a13; background: #c89b3c; border-color: #c89b3c; }
122
122
  #${PANEL_ID} .perf-chip--on:hover { background: #f0e6d2; }
123
123
 
124
+ #${PANEL_ID} .perf-tools { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; margin-bottom: 14px; }
125
+ #${PANEL_ID} .perf-search { flex: 1 1 auto; min-width: 0; box-sizing: border-box;
126
+ font-family: "LoL Body", Arial, "Helvetica Neue", Helvetica, sans-serif; font-size: 12px; letter-spacing: 0.3px;
127
+ color: #f0e6d2; background: #0a1428; border: 1px solid #785a28; padding: 9px 12px; outline: none; }
128
+ #${PANEL_ID} .perf-search:focus { border-color: #c89b3c; }
129
+ #${PANEL_ID} .perf-search::placeholder { color: #a09b8c; }
130
+ #${PANEL_ID} .perf-master { flex: 0 0 auto; white-space: nowrap; }
131
+ #${PANEL_ID} .perf-card--locked .perf-opts { pointer-events: none; }
132
+ #${PANEL_ID} .perf-card--locked .perf-actions > .perf-toggle { pointer-events: none; }
133
+ #${PANEL_ID} .perf-card--locked .perf-caret { opacity: 0.45; }
134
+ #${PANEL_ID} .perf-card--locked .perf-chips-label { opacity: 0.45; }
135
+ #${PANEL_ID} .perf-chip:disabled { opacity: 0.45; cursor: default; }
136
+ #${PANEL_ID} .perf-chip:disabled:hover { color: #a09b8c; border-color: #785a28; background: #0a1428; }
137
+ #${PANEL_ID} .perf-chip--on:disabled:hover { color: #010a13; border-color: #c89b3c; background: #c89b3c; }
138
+
124
139
  .perf-update { position: fixed; inset: 0; z-index: 100000; display: flex; align-items: center;
125
140
  justify-content: center; background: rgba(1, 10, 19, 0.72); }
126
141
  .perf-update-box { width: 430px; box-sizing: border-box; padding: 22px 26px;
@@ -145,9 +160,10 @@ let coords = null;
145
160
  const resolved = new Map();
146
161
  let availableModules = BUNDLED_MODULES;
147
162
  let pendingReload = false;
163
+ let searchQuery = "";
148
164
  let runningVersion = version;
149
165
 
150
- let state = { installed: {}, enabled: {}, options: {} };
166
+ let state = { installed: {}, enabled: {}, options: {}, masterLocked: false };
151
167
 
152
168
  async function loadState() {
153
169
  let saved = null;
@@ -160,7 +176,7 @@ async function loadState() {
160
176
  saved = null;
161
177
  }
162
178
  if (saved?.installed && typeof saved.installed === "object") {
163
- state = { installed: saved.installed, enabled: saved.enabled ?? {}, options: saved.options ?? {} };
179
+ state = { installed: saved.installed, enabled: saved.enabled ?? {}, options: saved.options ?? {}, masterLocked: saved.masterLocked === true };
164
180
  return;
165
181
  }
166
182
  saveState();
@@ -172,6 +188,7 @@ function saveState() {
172
188
  installed: state.installed,
173
189
  enabled: state.enabled,
174
190
  options: state.options,
191
+ masterLocked: state.masterLocked === true,
175
192
  };
176
193
  try {
177
194
  localStorage.setItem(STATE_KEY, JSON.stringify(record));
@@ -267,7 +284,7 @@ function getOption(id, key) {
267
284
  return stored === undefined ? Boolean(fallback) : Boolean(stored);
268
285
  }
269
286
 
270
- function setOption(id, key, value) {
287
+ function setOption(id, key, value, refresh = true) {
271
288
  if (!state.options[id]) state.options[id] = {};
272
289
  state.options[id][key] = Boolean(value);
273
290
  saveState();
@@ -277,7 +294,7 @@ function setOption(id, key, value) {
277
294
  } catch (error) {
278
295
  logError(`${entry?.name ?? id} could not apply the option change.`, error);
279
296
  }
280
- refreshPanel();
297
+ if (refresh) refreshPanel();
281
298
  }
282
299
 
283
300
  function getPlugins() {
@@ -524,16 +541,23 @@ function addStyle(name, css) {
524
541
  return () => element.remove();
525
542
  }
526
543
 
544
+ const LCU_TIMEOUT_MS = 10000;
545
+
527
546
  function lcuGet(path, { ttl = 0 } = {}) {
528
547
  const now = Date.now();
529
548
  const cached = requestCache.get(path);
530
549
  if (cached && (cached.pending || cached.expires > now)) return cached.promise;
531
550
 
532
551
  const entry = { pending: true, expires: 0, promise: null };
533
- entry.promise = fetch(path, { headers: { Accept: "application/json" } }).then(async (response) => {
552
+ const request = fetch(path, { headers: { Accept: "application/json" } }).then(async (response) => {
534
553
  if (!response.ok) throw new Error(`LCU ${path} responded ${response.status}`);
535
554
  return response.status === 204 ? null : response.json();
536
555
  });
556
+ let timer = null;
557
+ const timeout = new Promise((_, reject) => {
558
+ timer = setTimeout(() => reject(new Error(`LCU ${path} timed out`)), LCU_TIMEOUT_MS);
559
+ });
560
+ entry.promise = Promise.race([request, timeout]).finally(() => clearTimeout(timer));
537
561
  requestCache.set(path, entry);
538
562
  entry.promise.then(
539
563
  () => {
@@ -599,13 +623,13 @@ function buildOptionRow(mod, option, disabled, onChange) {
599
623
  input.setAttribute("aria-label", `${mod.name}: ${option.label}`);
600
624
  input.addEventListener("change", () => {
601
625
  if (onChange) onChange(input.checked);
602
- else setOption(mod.id, option.key, input.checked);
626
+ else setOption(mod.id, option.key, input.checked, false);
603
627
  });
604
628
  row.append(input, el("span", "perf-box"), el("span", "perf-opt-label", option.label));
605
629
  return row;
606
630
  }
607
631
 
608
- function buildChips(mod, option) {
632
+ function buildChips(mod, option, locked) {
609
633
  const block = el("div", "perf-chip-block");
610
634
  block.title = option.description ?? "";
611
635
  if (option.label) block.append(el("div", "perf-chips-label", option.label));
@@ -615,25 +639,29 @@ function buildChips(mod, option) {
615
639
  const chip = el("button", "perf-chip", item.label);
616
640
  chip.type = "button";
617
641
  chip.title = item.description ?? "";
642
+ chip.disabled = Boolean(locked);
618
643
  if (getOption(mod.id, key)) chip.classList.add("perf-chip--on");
619
- chip.addEventListener("click", () => setOption(mod.id, key, !getOption(mod.id, key)));
644
+ chip.addEventListener("click", () => {
645
+ setOption(mod.id, key, !getOption(mod.id, key), false);
646
+ chip.classList.toggle("perf-chip--on", getOption(mod.id, key));
647
+ });
620
648
  row.append(chip);
621
649
  }
622
650
  block.append(row);
623
651
  return block;
624
652
  }
625
653
 
626
- function buildOptions(mod) {
654
+ function buildOptions(mod, locked) {
627
655
  const options = registry.get(mod.id)?.options ?? [];
628
656
  if (options.length === 0) return null;
629
657
  const wrap = el("div", "perf-opts");
630
658
  for (const option of options) {
631
659
  if (option.type === "chips") {
632
- wrap.append(buildChips(mod, option));
660
+ wrap.append(buildChips(mod, option, locked));
633
661
  continue;
634
662
  }
635
663
  if (!option.children?.length) {
636
- wrap.append(buildOptionRow(mod, option));
664
+ wrap.append(buildOptionRow(mod, option, Boolean(locked)));
637
665
  continue;
638
666
  }
639
667
  const group = el("div", "perf-opt-group");
@@ -644,13 +672,13 @@ function buildOptions(mod) {
644
672
  const kids = el("div", "perf-opt-children");
645
673
  const childInputs = [];
646
674
  for (const child of option.children) {
647
- const childRow = buildOptionRow(mod, child, !getOption(mod.id, option.key));
675
+ const childRow = buildOptionRow(mod, child, !getOption(mod.id, option.key) || Boolean(locked));
648
676
  childInputs.push(childRow.querySelector("input"));
649
677
  kids.append(childRow);
650
678
  }
651
- const master = buildOptionRow(mod, option, false, (checked) => {
652
- for (const input of childInputs) input.disabled = !checked;
653
- setOption(mod.id, option.key, checked);
679
+ const master = buildOptionRow(mod, option, Boolean(locked), (checked) => {
680
+ for (const input of childInputs) input.disabled = !checked || Boolean(locked);
681
+ setOption(mod.id, option.key, checked, false);
654
682
  });
655
683
  const groupKey = `${mod.id}:${option.key}`;
656
684
  const setExpanded = (open) => {
@@ -671,12 +699,13 @@ function buildOptions(mod) {
671
699
  return wrap;
672
700
  }
673
701
 
674
- function buildToggle(mod) {
702
+ function buildToggle(mod, locked) {
675
703
  const label = el("label", "perf-toggle");
676
704
  label.title = `Enable ${mod.name}`;
677
705
  const input = el("input");
678
706
  input.type = "checkbox";
679
707
  input.checked = isPluginEnabled(mod.id);
708
+ input.disabled = Boolean(locked);
680
709
  input.setAttribute("aria-label", `Enable ${mod.name}`);
681
710
  input.addEventListener("change", () => setPluginEnabled(mod.id, input.checked));
682
711
  label.append(input, el("span", "perf-box"));
@@ -708,20 +737,90 @@ function buildUninstallButton(mod) {
708
737
  return button;
709
738
  }
710
739
 
711
- function buildCard(mod) {
740
+ function installedModules() {
741
+ return getAvailableModules().filter(isInstalled);
742
+ }
743
+
744
+ // The master switch is a UI lock only: it greys out every card's settings and
745
+ // toggle (same treatment as a disabled parent's child options) while each
746
+ // plugin keeps its own enabled/disabled state untouched.
747
+ function masterLocked() {
748
+ return installedModules().length > 0 && state.masterLocked === true;
749
+ }
750
+
751
+ function disableAllPlugins() {
752
+ state.masterLocked = true;
753
+ saveState();
754
+ refreshPanel();
755
+ }
756
+
757
+ function enableAllPlugins() {
758
+ state.masterLocked = false;
759
+ saveState();
760
+ refreshPanel();
761
+ }
762
+
763
+ function applySearchFilter(panel = document.getElementById(PANEL_ID)) {
764
+ if (!panel) return;
765
+ let shown = 0;
766
+ for (const card of panel.querySelectorAll(".perf-card")) {
767
+ const match = !searchQuery || (card.dataset.search ?? "").includes(searchQuery);
768
+ card.style.display = match ? "" : "none";
769
+ if (match) shown += 1;
770
+ }
771
+ const scroller = panel.querySelector("lol-uikit-scrollable");
772
+ let empty = panel.querySelector(".perf-no-match");
773
+ if (shown === 0 && getAvailableModules().length > 0) {
774
+ if (!empty && scroller) {
775
+ empty = el("div", "perf-empty perf-no-match", "No plugins match your search.");
776
+ scroller.append(empty);
777
+ }
778
+ } else if (empty) {
779
+ empty.remove();
780
+ }
781
+ }
782
+
783
+ function buildTools() {
784
+ const tools = el("div", "perf-tools");
785
+
786
+ const search = el("input", "perf-search");
787
+ search.type = "search";
788
+ search.placeholder = "Search plugins...";
789
+ search.value = searchQuery;
790
+ search.setAttribute("aria-label", "Search plugins");
791
+ search.addEventListener("input", () => {
792
+ searchQuery = search.value.trim().toLowerCase();
793
+ applySearchFilter();
794
+ });
795
+
796
+ const master = el("button", "perf-btn perf-master", masterLocked() ? "Enable All" : "Disable All");
797
+ master.type = "button";
798
+ master.disabled = installedModules().length === 0;
799
+ master.addEventListener("click", () => {
800
+ if (masterLocked()) enableAllPlugins();
801
+ else disableAllPlugins();
802
+ });
803
+
804
+ tools.append(search, master);
805
+ return tools;
806
+ }
807
+
808
+ function buildCard(mod, locked) {
712
809
  const installed = isInstalled(mod);
713
810
  const card = el("div", installed ? "perf-card" : "perf-card perf-card--missing");
811
+ if (locked) card.classList.add("perf-card--locked");
812
+ card.dataset.search = `${mod.name} ${mod.description}`.toLowerCase();
714
813
 
715
814
  const info = el("div", "perf-info");
716
815
  const name = el("div", "perf-name", mod.name);
717
816
  name.append(el("span", "perf-version", `v${registry.get(mod.id)?.version ?? mod.version}`));
718
817
  info.append(name);
719
818
  if (mod.description) info.append(el("div", "perf-desc", mod.description));
720
- const optionList = buildOptions(mod);
819
+ const optionList = buildOptions(mod, locked);
721
820
  if (optionList) info.append(optionList);
722
821
 
723
822
  const actions = el("div", "perf-actions");
724
- if (installed) actions.append(buildToggle(mod));
823
+ if (installed) actions.append(buildToggle(mod, locked));
725
824
  actions.append(installed ? buildUninstallButton(mod) : buildInstallButton(mod));
726
825
 
727
826
  card.append(info, actions);
@@ -731,6 +830,8 @@ function buildCard(mod) {
731
830
  function buildPanel() {
732
831
  const panel = el("div");
733
832
  panel.id = PANEL_ID;
833
+ panel.append(buildTools());
834
+
734
835
  const scroller = el("lol-uikit-scrollable");
735
836
  panel.append(scroller);
736
837
 
@@ -739,13 +840,31 @@ function buildPanel() {
739
840
  scroller.append(el("div", "perf-empty", "No plugins are available."));
740
841
  return panel;
741
842
  }
742
- for (const mod of modules) scroller.append(buildCard(mod));
843
+ const locked = masterLocked();
844
+ for (const mod of modules) scroller.append(buildCard(mod, locked));
845
+ applySearchFilter(panel);
743
846
  return panel;
744
847
  }
745
848
 
849
+ function panelScrollTop(panel) {
850
+ return panel?.querySelector("lol-uikit-scrollable")?.scrollTop ?? 0;
851
+ }
852
+
853
+ function applyScrollTop(panel, top) {
854
+ if (!top || !panel) return;
855
+ const host = panel.querySelector("lol-uikit-scrollable");
856
+ if (host) host.scrollTop = top;
857
+ }
858
+
746
859
  function refreshPanel() {
747
860
  const current = document.getElementById(PANEL_ID);
748
- if (current) current.replaceWith(buildPanel());
861
+ if (!current) return;
862
+ const top = panelScrollTop(current);
863
+ const next = buildPanel();
864
+ current.replaceWith(next);
865
+ if (!top) return;
866
+ applyScrollTop(next, top);
867
+ requestAnimationFrame(() => applyScrollTop(next, top));
749
868
  }
750
869
 
751
870
  function setNavActive(activeNav) {