perfectionist-plugins 1.8.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.1.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;
@@ -39,6 +39,13 @@ const CLASH_BULK_ACKS = [
39
39
  "/lol-clash/v1/eog-player-update/acknowledge",
40
40
  "/lol-clash/v1/game-end/acknowledge",
41
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";
42
49
  const PIP_STYLE_ID = "auto-read-pips-style";
43
50
  const PIP_CSS = ".activity-center__tab_label_notification { display: none !important; }";
44
51
 
@@ -201,6 +208,65 @@ async function sweepNotices() {
201
208
  }
202
209
  }
203
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
+
204
270
  function syncPips() {
205
271
  const wanted = active && on("pips");
206
272
  let style = document.getElementById(PIP_STYLE_ID);
@@ -230,7 +296,11 @@ async function runSweep() {
230
296
  try {
231
297
  if (on("quests")) await sweepQuests().catch(() => {});
232
298
  if (on("loot")) await sweepLootMarks().catch(() => {});
233
- if (on("rewards")) await sweepRewards().catch(() => {});
299
+ if (on("rewards")) {
300
+ await sweepRewards().catch(() => {});
301
+ await sweepIdentity().catch(() => {});
302
+ }
303
+ if (on("tft")) await sweepTft().catch(() => {});
234
304
  if (on("clash")) await sweepClash().catch(() => {});
235
305
  if (on("ranked")) await sweepRanked().catch(() => {});
236
306
  if (on("notices")) await sweepNotices().catch(() => {});
@@ -247,7 +317,7 @@ function onEnable(utils) {
247
317
  api = utils;
248
318
  active = true;
249
319
  cooldowns.clear();
250
- 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]) {
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]) {
251
321
  try {
252
322
  const off = api.subscribe(uri, scheduleSweep);
253
323
  if (off) unsubscribes.push(off);
@@ -286,7 +356,7 @@ export function init() {
286
356
  utils.registerPlugin({
287
357
  id: PLUGIN_ID,
288
358
  name: "Auto Read",
289
- 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.",
290
360
  version,
291
361
  defaultEnabled: true,
292
362
  options: [
@@ -299,6 +369,7 @@ export function init() {
299
369
  { id: "quests", label: "Quests", description: "Quest and mission badges, including the mission button counter.", default: true },
300
370
  { id: "loot", label: "Loot marks", description: "The new mark on freshly earned loot.", default: true },
301
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 },
302
373
  { id: "clash", label: "Clash", description: "Clash tab flags and Clash notifications.", default: true },
303
374
  { id: "ranked", label: "Ranked", description: "Ranked, end-of-season and champion mastery notices.", default: true },
304
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.8.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.7.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.1.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));
@@ -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
  () => {
@@ -605,7 +629,7 @@ function buildOptionRow(mod, option, disabled, onChange) {
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,6 +639,7 @@ 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
644
  chip.addEventListener("click", () => {
620
645
  setOption(mod.id, key, !getOption(mod.id, key), false);
@@ -626,17 +651,17 @@ function buildChips(mod, option) {
626
651
  return block;
627
652
  }
628
653
 
629
- function buildOptions(mod) {
654
+ function buildOptions(mod, locked) {
630
655
  const options = registry.get(mod.id)?.options ?? [];
631
656
  if (options.length === 0) return null;
632
657
  const wrap = el("div", "perf-opts");
633
658
  for (const option of options) {
634
659
  if (option.type === "chips") {
635
- wrap.append(buildChips(mod, option));
660
+ wrap.append(buildChips(mod, option, locked));
636
661
  continue;
637
662
  }
638
663
  if (!option.children?.length) {
639
- wrap.append(buildOptionRow(mod, option));
664
+ wrap.append(buildOptionRow(mod, option, Boolean(locked)));
640
665
  continue;
641
666
  }
642
667
  const group = el("div", "perf-opt-group");
@@ -647,12 +672,12 @@ function buildOptions(mod) {
647
672
  const kids = el("div", "perf-opt-children");
648
673
  const childInputs = [];
649
674
  for (const child of option.children) {
650
- const childRow = buildOptionRow(mod, child, !getOption(mod.id, option.key));
675
+ const childRow = buildOptionRow(mod, child, !getOption(mod.id, option.key) || Boolean(locked));
651
676
  childInputs.push(childRow.querySelector("input"));
652
677
  kids.append(childRow);
653
678
  }
654
- const master = buildOptionRow(mod, option, false, (checked) => {
655
- for (const input of childInputs) input.disabled = !checked;
679
+ const master = buildOptionRow(mod, option, Boolean(locked), (checked) => {
680
+ for (const input of childInputs) input.disabled = !checked || Boolean(locked);
656
681
  setOption(mod.id, option.key, checked, false);
657
682
  });
658
683
  const groupKey = `${mod.id}:${option.key}`;
@@ -674,12 +699,13 @@ function buildOptions(mod) {
674
699
  return wrap;
675
700
  }
676
701
 
677
- function buildToggle(mod) {
702
+ function buildToggle(mod, locked) {
678
703
  const label = el("label", "perf-toggle");
679
704
  label.title = `Enable ${mod.name}`;
680
705
  const input = el("input");
681
706
  input.type = "checkbox";
682
707
  input.checked = isPluginEnabled(mod.id);
708
+ input.disabled = Boolean(locked);
683
709
  input.setAttribute("aria-label", `Enable ${mod.name}`);
684
710
  input.addEventListener("change", () => setPluginEnabled(mod.id, input.checked));
685
711
  label.append(input, el("span", "perf-box"));
@@ -711,20 +737,90 @@ function buildUninstallButton(mod) {
711
737
  return button;
712
738
  }
713
739
 
714
- 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) {
715
809
  const installed = isInstalled(mod);
716
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();
717
813
 
718
814
  const info = el("div", "perf-info");
719
815
  const name = el("div", "perf-name", mod.name);
720
816
  name.append(el("span", "perf-version", `v${registry.get(mod.id)?.version ?? mod.version}`));
721
817
  info.append(name);
722
818
  if (mod.description) info.append(el("div", "perf-desc", mod.description));
723
- const optionList = buildOptions(mod);
819
+ const optionList = buildOptions(mod, locked);
724
820
  if (optionList) info.append(optionList);
725
821
 
726
822
  const actions = el("div", "perf-actions");
727
- if (installed) actions.append(buildToggle(mod));
823
+ if (installed) actions.append(buildToggle(mod, locked));
728
824
  actions.append(installed ? buildUninstallButton(mod) : buildInstallButton(mod));
729
825
 
730
826
  card.append(info, actions);
@@ -734,6 +830,8 @@ function buildCard(mod) {
734
830
  function buildPanel() {
735
831
  const panel = el("div");
736
832
  panel.id = PANEL_ID;
833
+ panel.append(buildTools());
834
+
737
835
  const scroller = el("lol-uikit-scrollable");
738
836
  panel.append(scroller);
739
837
 
@@ -742,7 +840,9 @@ function buildPanel() {
742
840
  scroller.append(el("div", "perf-empty", "No plugins are available."));
743
841
  return panel;
744
842
  }
745
- 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);
746
846
  return panel;
747
847
  }
748
848