perfectionist-plugins 1.9.0 → 2.4.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.4.0";
1
+ export const version = "2.5.1";
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";
@@ -17,7 +18,7 @@ const RETRY_MS = 5000;
17
18
  const STATE_KEY = "perfectionist-plugins:state";
18
19
 
19
20
  const CSS = `
20
- .${HIDDEN_CLASS} { display: none !important; margin: 0 !important; padding: 0 !important; border: none !important; }
21
+ .${HIDDEN_CLASS} { display: none !important; margin: 0 !important; padding: 0 !important; border: none !important; pointer-events: none !important; }
21
22
  [data-perf-dots] { justify-content: center !important; }
22
23
  `;
23
24
 
@@ -161,13 +162,13 @@ function installFetchFilter() {
161
162
 
162
163
  const wrapped = function (input, init) {
163
164
  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
- : "";
165
+ typeof input === "string"
166
+ ? input
167
+ : typeof input?.url === "string"
168
+ ? input.url
169
+ : typeof input?.href === "string"
170
+ ? input.href
171
+ : "";
171
172
  const promise = original.call(this, input, init);
172
173
  if (!filterActive || url.split("?")[0].indexOf(CAROUSEL_URI) === -1) return promise;
173
174
  return promise.then((response) => {
@@ -223,8 +224,7 @@ function installXhrFilter() {
223
224
  const kept = filterSkinList(json);
224
225
  recordClientOrder(kept ?? json);
225
226
  if (kept) cache.filtered = JSON.stringify(kept);
226
- } catch {
227
- }
227
+ } catch {}
228
228
  }
229
229
  return cache.filtered;
230
230
  };
@@ -270,6 +270,7 @@ let allSkinIds = null;
270
270
  let currentChampionId = null;
271
271
  let carouselOrder = [];
272
272
  let selectedSkinId = null;
273
+ let lastServerSkinId = null;
273
274
  let failure = null;
274
275
  let requestToken = 0;
275
276
  let refreshTimer = null;
@@ -279,11 +280,17 @@ let selectInFlight = false;
279
280
  let sessionMisses = 0;
280
281
  let championMisses = 0;
281
282
  let renderObserver = null;
283
+ let sessionActive = false;
284
+ let repackTimer = null;
285
+ let removeStyle = null;
286
+ let lastSelectedDomIndex = -1;
287
+ let navFixAt = 0;
288
+ let lastSkipSignature = null;
289
+ let itemSkinIds = new WeakMap();
282
290
  const repackOriginals = new Map();
283
291
  const lastAppliedLeft = new Map();
284
292
  const skinSuffixCache = new Map();
285
- let repackTimer = null;
286
- let removeStyle = null;
293
+ const widthCache = new Map();
287
294
 
288
295
  function logError(scope, error) {
289
296
  console.error(`[Perfectionist] Hide Unowned Skins: ${scope}`, error);
@@ -295,7 +302,8 @@ function skinSuffixOf(text) {
295
302
  return /Skins\/Base\//i.test(text) ? 0 : null;
296
303
  }
297
304
 
298
- function readSkinId(item) {
305
+ function skinIdFromImages(item) {
306
+ if (currentChampionId === null) return null;
299
307
  for (const img of item.querySelectorAll("img[src]")) {
300
308
  const src = img.getAttribute("src");
301
309
  let suffix = skinSuffixCache.get(src);
@@ -309,6 +317,25 @@ function readSkinId(item) {
309
317
  return suffix === null ? null : currentChampionId * 1000 + suffix;
310
318
  }
311
319
 
320
+ function readSkinId(item) {
321
+ return itemSkinIds.get(item) ?? skinIdFromImages(item);
322
+ }
323
+
324
+ function effectiveOrder() {
325
+ return clientSkinOrder.length > 0 ? clientSkinOrder : carouselOrder;
326
+ }
327
+
328
+ function mapCarouselToItems(items) {
329
+ const order = effectiveOrder();
330
+ itemSkinIds = new WeakMap();
331
+ if (order.length === 0 || order.length !== items.length) return false;
332
+ items.forEach((item, index) => {
333
+ const id = order[index];
334
+ if (typeof id === "number") itemSkinIds.set(item, id);
335
+ });
336
+ return true;
337
+ }
338
+
312
339
  function hideTargetFor(item) {
313
340
  let target = item;
314
341
  for (let depth = 0; depth < 2; depth++) {
@@ -327,6 +354,73 @@ function pruneRepack() {
327
354
  }
328
355
  }
329
356
 
357
+ function guardSelection(items) {
358
+ const selectedIndex = items.findIndex((item) => item.classList.contains(SELECTED_CLASS));
359
+ if (selectedIndex < 0) return false;
360
+ const previous = lastSelectedDomIndex;
361
+ lastSelectedDomIndex = selectedIndex;
362
+ const selected = items[selectedIndex];
363
+ const hidden = selected.classList.contains(HIDDEN_CLASS) || Boolean(selected.parentElement?.classList.contains(HIDDEN_CLASS));
364
+ if (!hidden) {
365
+ lastSkipSignature = null;
366
+ return false;
367
+ }
368
+
369
+ const visible = items.map((item, index) => (item.classList.contains(HIDDEN_CLASS) ? -1 : index)).filter((index) => index >= 0);
370
+ if (visible.length === 0) return false;
371
+
372
+ const forward = previous < 0 ? true : selectedIndex >= previous;
373
+ const target = forward
374
+ ? (visible.find((index) => index > selectedIndex) ?? visible[0])
375
+ : ([...visible].reverse().find((index) => index < selectedIndex) ?? visible[visible.length - 1]);
376
+ const skinId = readSkinId(items[target]);
377
+ if (skinId === null) return false;
378
+
379
+ const signature = `${selectedIndex}:${skinId}`;
380
+ if (lastSkipSignature === signature) return true;
381
+ const now = Date.now();
382
+ if (now < navFixAt) return true;
383
+ navFixAt = now + 150;
384
+ lastSkipSignature = signature;
385
+ lastSelectedDomIndex = target;
386
+ selectSkin(skinId).catch((error) => logError("could not skip the unowned skin.", error));
387
+ return true;
388
+ }
389
+
390
+ function leftToPx(item, raw) {
391
+ if (!raw) return 0;
392
+ if (raw.endsWith("px")) return parseFloat(raw);
393
+ if (raw.endsWith("%")) return (parseFloat(raw) / 100) * (item.parentElement?.offsetWidth || 468);
394
+ return 0;
395
+ }
396
+
397
+ function originalLeftOf(item) {
398
+ const original = repackOriginals.get(item);
399
+ return leftToPx(item, original ? original.value : item.style.getPropertyValue("left"));
400
+ }
401
+
402
+ function refreshOriginal(item) {
403
+ const current = item.style.getPropertyValue("left");
404
+ const priority = item.style.getPropertyPriority("left");
405
+ const last = lastAppliedLeft.get(item);
406
+ if (last !== undefined && current === last && priority === "important") return;
407
+ repackOriginals.set(item, { value: current, priority });
408
+ lastAppliedLeft.delete(item);
409
+ }
410
+
411
+ function widthOf(item, anchor) {
412
+ if (item.offsetWidth > 0) {
413
+ widthCache.set(item, item.offsetWidth);
414
+ return item.offsetWidth;
415
+ }
416
+ const measured = item.getBoundingClientRect().width;
417
+ if (measured > 0) {
418
+ widthCache.set(item, measured);
419
+ return measured;
420
+ }
421
+ return widthCache.get(item) ?? (item === anchor ? 117 : 73);
422
+ }
423
+
330
424
  function repackVisibleItems(items) {
331
425
  pruneRepack();
332
426
  const visible = items.filter((item) => !item.classList.contains(HIDDEN_CLASS));
@@ -335,25 +429,15 @@ function repackVisibleItems(items) {
335
429
  return;
336
430
  }
337
431
 
338
- const selectedIndex = items.findIndex((item) => item.classList.contains("skin-selection-item-selected"));
432
+ const selectedIndex = items.findIndex((item) => item.classList.contains(SELECTED_CLASS));
339
433
  let anchor = selectedIndex >= 0 ? items[selectedIndex] : null;
340
434
  if (!anchor && selectedSkinId !== null) {
341
435
  anchor = visible.find((item) => readSkinId(item) === selectedSkinId) ?? null;
342
436
  }
343
437
  if (!anchor) anchor = visible[Math.floor(visible.length / 2)];
344
438
  const anchorDomIndex = items.indexOf(anchor);
345
-
346
- const widthOf = (item) => item.getBoundingClientRect().width || (item === anchor ? 117 : 73);
347
- const toPx = (item) => {
348
- const raw = item.style.left || "0";
349
- if (raw.endsWith("px")) return parseFloat(raw);
350
- const box = item.parentElement?.getBoundingClientRect().width || 468;
351
- return (parseFloat(raw) / 100) * box;
352
- };
353
-
354
- const anchorLeft = toPx(anchor);
355
- let rightX = anchorLeft + widthOf(anchor);
356
- let leftX = anchorLeft;
439
+ refreshOriginal(anchor);
440
+ const anchorLeft = originalLeftOf(anchor);
357
441
 
358
442
  const setLeft = (item, x) => {
359
443
  const want = `${x}px`;
@@ -370,19 +454,23 @@ function repackVisibleItems(items) {
370
454
  lastAppliedLeft.set(item, want);
371
455
  };
372
456
 
373
- items.forEach((item, index) => {
374
- if (item === anchor || item.classList.contains(HIDDEN_CLASS)) return;
457
+ let rightX = anchorLeft + widthOf(anchor, anchor);
458
+ for (let index = anchorDomIndex + 1; index < items.length; index++) {
459
+ const item = items[index];
460
+ if (item.classList.contains(HIDDEN_CLASS)) continue;
461
+ const width = widthOf(item, anchor);
462
+ setLeft(item, rightX);
463
+ rightX += width;
464
+ }
375
465
 
376
- const isRight = index > anchorDomIndex;
377
- const w = widthOf(item);
378
- if (isRight) {
379
- setLeft(item, rightX);
380
- rightX += w;
381
- } else {
382
- leftX -= w;
383
- setLeft(item, leftX);
384
- }
385
- });
466
+ let leftX = anchorLeft;
467
+ for (let index = anchorDomIndex - 1; index >= 0; index--) {
468
+ const item = items[index];
469
+ if (item.classList.contains(HIDDEN_CLASS)) continue;
470
+ const width = widthOf(item, anchor);
471
+ leftX -= width;
472
+ setLeft(item, leftX);
473
+ }
386
474
  }
387
475
 
388
476
  function restoreRepack() {
@@ -400,6 +488,7 @@ function restoreRepack() {
400
488
 
401
489
  function applyAll() {
402
490
  const items = [...document.querySelectorAll(SKIN_ITEM_SELECTOR)];
491
+ mapCarouselToItems(items);
403
492
  let hidden = 0;
404
493
  items.forEach((item) => {
405
494
  const skinId = currentChampionId === null ? null : readSkinId(item);
@@ -411,9 +500,10 @@ function applyAll() {
411
500
 
412
501
  const dots = [...document.querySelectorAll(DOT_SELECTOR)];
413
502
  let dotsHidden = 0;
414
- const dotsMapped = carouselOrder.length > 0 && dots.length === carouselOrder.length;
503
+ const dotOrder = effectiveOrder();
504
+ const dotsMapped = dotOrder.length > 0 && dots.length === dotOrder.length;
415
505
  dots.forEach((dot, index) => {
416
- const skinId = dotsMapped ? carouselOrder[index] : undefined;
506
+ const skinId = dotsMapped ? dotOrder[index] : undefined;
417
507
  const hide = ownedSkinIds !== null && skinId !== undefined && allSkinIds !== null && allSkinIds.has(skinId) && !ownedSkinIds.has(skinId);
418
508
  if (hide) dotsHidden++;
419
509
  dot.classList.toggle(HIDDEN_CLASS, hide);
@@ -424,8 +514,10 @@ function applyAll() {
424
514
  else if (dotsHidden === 0 && dotList.hasAttribute("data-perf-dots")) dotList.removeAttribute("data-perf-dots");
425
515
  }
426
516
 
427
- if (ownedSkinIds !== null && hidden > 0) scheduleRepack();
428
- else if (hidden === 0) restoreRepack();
517
+ if (ownedSkinIds !== null && hidden > 0) {
518
+ if (guardSelection(items)) runRepack();
519
+ else scheduleRepack();
520
+ } else if (hidden === 0) restoreRepack();
429
521
  }
430
522
 
431
523
  function scheduleRepack() {
@@ -433,6 +525,27 @@ function scheduleRepack() {
433
525
  repackTimer = setTimeout(runRepack, REPACK_SETTLE_MS);
434
526
  }
435
527
 
528
+ function startRenderObserver() {
529
+ if (!sessionActive || renderObserver || !api) return;
530
+ renderObserver = new MutationObserver(() => {
531
+ ensureSourceFilter();
532
+ const item = document.querySelector(SKIN_ITEM_SELECTOR);
533
+ if (!item && !document.querySelector(DOT_SELECTOR)) return;
534
+ applyAllGuarded();
535
+ if (item) scheduleRefresh();
536
+ });
537
+ try {
538
+ renderObserver.observe(document.documentElement ?? document, OBSERVER_OPTIONS);
539
+ } catch {
540
+ renderObserver = null;
541
+ }
542
+ }
543
+
544
+ function stopRenderObserver() {
545
+ renderObserver?.disconnect();
546
+ renderObserver = null;
547
+ }
548
+
436
549
  function runRepack() {
437
550
  repackTimer = null;
438
551
  renderObserver?.disconnect();
@@ -442,7 +555,7 @@ function runRepack() {
442
555
  if (ownedSkinIds !== null && hidden) repackVisibleItems(items);
443
556
  else restoreRepack();
444
557
  } finally {
445
- renderObserver?.observe(document.documentElement ?? document, OBSERVER_OPTIONS);
558
+ startRenderObserver();
446
559
  }
447
560
  }
448
561
 
@@ -451,7 +564,7 @@ function applyAllGuarded() {
451
564
  try {
452
565
  applyAll();
453
566
  } finally {
454
- renderObserver?.observe(document.documentElement ?? document, OBSERVER_OPTIONS);
567
+ startRenderObserver();
455
568
  }
456
569
  }
457
570
 
@@ -585,11 +698,13 @@ function updateForSession(session, selection) {
585
698
 
586
699
  const knownUnownedSkin = allSkinIds !== null && allSkinIds.has(serverSkinId) && !ownedSkinIds.has(serverSkinId);
587
700
  if (!selectInFlight && knownUnownedSkin) {
588
- const fallback = nextOwnedSkin(ownedSkinIds, 0, 1);
701
+ const direction = lastServerSkinId !== null && serverSkinId !== null && serverSkinId < lastServerSkinId ? -1 : 1;
702
+ const fallback = nextOwnedSkin(ownedSkinIds, serverSkinId ?? 0, direction) ?? nextOwnedSkin(ownedSkinIds, 0, 1);
589
703
  if (fallback !== null) {
590
704
  selectSkin(fallback).catch((error) => logError("could not correct the selected skin.", error));
591
705
  }
592
706
  }
707
+ if (!selectInFlight && belongsToChampion && ownedSkinIds.has(serverSkinId)) lastServerSkinId = serverSkinId;
593
708
 
594
709
  applyAllGuarded();
595
710
  }
@@ -606,11 +721,15 @@ function scheduleRefresh() {
606
721
  function onSessionPush(raw) {
607
722
  const msg = raw ?? {};
608
723
  if (msg.eventType === "Delete") {
724
+ sessionActive = false;
725
+ stopRenderObserver();
609
726
  resetState();
610
727
  return;
611
728
  }
612
729
  const s = msg.data ?? msg;
613
730
  if (!s?.myTeam) return;
731
+ sessionActive = true;
732
+ startRenderObserver();
614
733
  updateForSession(s, null);
615
734
  }
616
735
 
@@ -621,11 +740,17 @@ async function pollSession() {
621
740
  try {
622
741
  const session = await api.lcu.get(SESSION_URI);
623
742
  sessionMisses = 0;
743
+ sessionActive = true;
744
+ startRenderObserver();
624
745
  const selection = await api.lcu.get(SELECTION_URI).catch(() => null);
625
746
  updateForSession(session, selection);
626
747
  } catch {
627
748
  sessionMisses++;
628
- if (sessionMisses >= 3) resetState();
749
+ if (sessionMisses >= 3) {
750
+ sessionActive = false;
751
+ stopRenderObserver();
752
+ resetState();
753
+ }
629
754
  } finally {
630
755
  pollInFlight = false;
631
756
  }
@@ -634,17 +759,9 @@ async function pollSession() {
634
759
  function onEnable(utils) {
635
760
  api = utils;
636
761
  filterActive = true;
762
+ sessionActive = false;
637
763
  removeStyle = api.addStyle(STYLE_ID, CSS);
638
764
 
639
- renderObserver = new MutationObserver(() => {
640
- ensureSourceFilter();
641
- const item = document.querySelector(SKIN_ITEM_SELECTOR);
642
- if (!item && !document.querySelector(DOT_SELECTOR)) return;
643
- applyAllGuarded();
644
- if (item) scheduleRefresh();
645
- });
646
- renderObserver.observe(document.documentElement ?? document, OBSERVER_OPTIONS);
647
-
648
765
  window.addEventListener("resize", onWindowResize);
649
766
  unsubscribe = api.subscribe(SESSION_URI, onSessionPush);
650
767
  pollSession();
@@ -658,9 +775,9 @@ function onDisable() {
658
775
  refreshTimer = null;
659
776
  clearTimeout(repackTimer);
660
777
  repackTimer = null;
661
- renderObserver?.disconnect();
778
+ sessionActive = false;
779
+ stopRenderObserver();
662
780
  removeStyle?.();
663
- renderObserver = null;
664
781
  removeStyle = null;
665
782
  filterActive = false;
666
783
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perfectionist-plugins",
3
- "version": "1.9.0",
3
+ "version": "2.4.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",
@@ -11,7 +11,8 @@
11
11
  "./hide-unowned-skins.js": "./hide-unowned-skins.js",
12
12
  "./unlock-profile-skins.js": "./unlock-profile-skins.js",
13
13
  "./auto-claimer.js": "./auto-claimer.js",
14
- "./auto-read.js": "./auto-read.js"
14
+ "./auto-read.js": "./auto-read.js",
15
+ "./better-client.js": "./better-client.js"
15
16
  },
16
17
  "files": [
17
18
  "perfectionist-utils.js",
@@ -19,7 +20,8 @@
19
20
  "hide-unowned-skins.js",
20
21
  "unlock-profile-skins.js",
21
22
  "auto-claimer.js",
22
- "auto-read.js"
23
+ "auto-read.js",
24
+ "better-client.js"
23
25
  ],
24
26
  "repository": {
25
27
  "type": "git",
@@ -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.8.0";
9
+ export const version = "2.4.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.7.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,20 +36,20 @@ 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.4.0",
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",
42
42
  name: "Unlock Profile Skins",
43
- version: "1.2.1",
43
+ version: "1.2.4",
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.5.1",
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",
@@ -120,6 +127,9 @@ const STYLE_CSS = `
120
127
  #${PANEL_ID} .perf-chip:hover { color: #f0e6d2; border-color: #c89b3c; }
121
128
  #${PANEL_ID} .perf-chip--on { color: #010a13; background: #c89b3c; border-color: #c89b3c; }
122
129
  #${PANEL_ID} .perf-chip--on:hover { background: #f0e6d2; }
130
+ #${PANEL_ID} .perf-info { margin-top: 11px; padding-top: 8px; border-top: 1px solid rgba(120, 90, 40, 0.35); }
131
+ #${PANEL_ID} .perf-info-line { font-size: 10px; line-height: 16px; letter-spacing: 0.2px; color: #a09b8c;
132
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
123
133
 
124
134
  #${PANEL_ID} .perf-tools { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; margin-bottom: 14px; }
125
135
  #${PANEL_ID} .perf-search { flex: 1 1 auto; min-width: 0; box-sizing: border-box;
@@ -334,6 +344,14 @@ function importModule(url) {
334
344
  return coords?.importModule ? coords.importModule(url) : import(url);
335
345
  }
336
346
 
347
+ // Lets a feature module (BetterClient → Pages Remake) pull its code only when enabled.
348
+ // A local override keeps the dev harness on freshly edited files instead of the CDN copy.
349
+ function loadPluginFile(file) {
350
+ const local = typeof window !== "undefined" ? window.PerfectionistLocalBase : null;
351
+ const url = local ? new URL(file, local).href : moduleUrl(file);
352
+ return importModule(url);
353
+ }
354
+
337
355
  async function installModule(mod) {
338
356
  state.installed[mod.id] = true;
339
357
  delete state.enabled[mod.id];
@@ -629,6 +647,17 @@ function buildOptionRow(mod, option, disabled, onChange) {
629
647
  return row;
630
648
  }
631
649
 
650
+ function buildInfo(option) {
651
+ const block = el("div", "perf-info");
652
+ if (option.label) block.append(el("div", "perf-chips-label", option.label));
653
+ let lines = [];
654
+ try {
655
+ lines = option.lines?.() ?? [];
656
+ } catch {}
657
+ for (const line of lines) block.append(el("div", "perf-info-line", String(line)));
658
+ return block;
659
+ }
660
+
632
661
  function buildChips(mod, option, locked) {
633
662
  const block = el("div", "perf-chip-block");
634
663
  block.title = option.description ?? "";
@@ -651,6 +680,28 @@ function buildChips(mod, option, locked) {
651
680
  return block;
652
681
  }
653
682
 
683
+ function buildAction(option, locked) {
684
+ const button = el("button", "perf-btn", option.label);
685
+ button.type = "button";
686
+ button.title = option.description ?? "";
687
+ button.disabled = Boolean(locked);
688
+ button.addEventListener("click", async () => {
689
+ const original = button.textContent;
690
+ button.disabled = true;
691
+ try {
692
+ await option.onClick?.();
693
+ button.textContent = "Copied";
694
+ } catch {
695
+ button.textContent = "Copy failed";
696
+ }
697
+ setTimeout(() => {
698
+ button.textContent = original;
699
+ button.disabled = Boolean(locked);
700
+ }, 1600);
701
+ });
702
+ return button;
703
+ }
704
+
654
705
  function buildOptions(mod, locked) {
655
706
  const options = registry.get(mod.id)?.options ?? [];
656
707
  if (options.length === 0) return null;
@@ -660,6 +711,14 @@ function buildOptions(mod, locked) {
660
711
  wrap.append(buildChips(mod, option, locked));
661
712
  continue;
662
713
  }
714
+ if (option.type === "info") {
715
+ wrap.append(buildInfo(option));
716
+ continue;
717
+ }
718
+ if (option.type === "action") {
719
+ wrap.append(buildAction(option, locked));
720
+ continue;
721
+ }
663
722
  if (!option.children?.length) {
664
723
  wrap.append(buildOptionRow(mod, option, Boolean(locked)));
665
724
  continue;
@@ -741,9 +800,6 @@ function installedModules() {
741
800
  return getAvailableModules().filter(isInstalled);
742
801
  }
743
802
 
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
803
  function masterLocked() {
748
804
  return installedModules().length > 0 && state.masterLocked === true;
749
805
  }
@@ -1016,6 +1072,7 @@ const utils = {
1016
1072
  getAvailableModules,
1017
1073
  installModule,
1018
1074
  uninstallModule,
1075
+ loadPluginFile,
1019
1076
  checkForUpdates,
1020
1077
  watch,
1021
1078
  waitFor,