perfectionist-plugins 1.4.1 → 1.8.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-claimer.js CHANGED
@@ -1,5 +1,5 @@
1
1
  const PLUGIN_ID = "auto-claimer";
2
- const version = "1.0.0";
2
+ const version = "1.2.0";
3
3
 
4
4
  const SWEEP_DEBOUNCE_MS = 400;
5
5
  const CLAIM_COOLDOWN_MS = 5 * 60 * 1000;
@@ -29,7 +29,7 @@ let shopQueued = false;
29
29
  let unsubscribes = [];
30
30
  let sourceInstalled = false;
31
31
  const cooldowns = new Map();
32
- const menus = new Map();
32
+ const recipes = new Map();
33
33
 
34
34
  function on(key) {
35
35
  try {
@@ -113,20 +113,26 @@ async function sweepTftPasses() {
113
113
  }
114
114
  }
115
115
 
116
- function menuFor(lootId) {
117
- if (!menus.has(lootId)) {
118
- const promise = getJson(`/lol-loot/v1/player-loot/${encodeURIComponent(lootId)}/context-menu`).catch(() => {
119
- menus.delete(lootId);
120
- return null;
121
- });
122
- menus.set(lootId, promise);
116
+ function recipeFor(lootId) {
117
+ if (!recipes.has(lootId)) {
118
+ const promise = getJson(`/lol-loot/v1/recipes/initial-item/${encodeURIComponent(lootId)}`)
119
+ .then((list) => (Array.isArray(list) ? (list.find((entry) => entry?.type === "OPEN" && entry.recipeName) ?? null) : null))
120
+ .catch(() => {
121
+ recipes.delete(lootId);
122
+ return null;
123
+ });
124
+ recipes.set(lootId, promise);
123
125
  }
124
- return menus.get(lootId);
126
+ return recipes.get(lootId);
125
127
  }
126
128
 
127
129
  async function sweepLoot() {
128
130
  const loot = await getJson(LOOT_URI);
129
131
  if (!Array.isArray(loot)) return;
132
+ const owned = new Map();
133
+ for (const item of loot) {
134
+ if (item?.lootId) owned.set(item.lootId, item.count ?? 0);
135
+ }
130
136
  let actions = 0;
131
137
  for (const item of loot) {
132
138
  if (actions >= MAX_ACTIONS_PER_SWEEP) break;
@@ -134,15 +140,32 @@ async function sweepLoot() {
134
140
  const count = item?.count ?? 0;
135
141
  if (!lootId || count <= 0 || /^(CURRENCY|MATERIAL)_/.test(lootId)) continue;
136
142
  if (!ready(`loot:${lootId}`, CLAIM_COOLDOWN_MS)) continue;
137
- const menu = await menuFor(lootId);
138
- const open = (Array.isArray(menu) ? menu : []).find((action) => action?.actionType === "OPEN" && action.name && action.enabled !== false);
139
- if (!open) continue;
140
- const needsKey = (open.requiredOthersCount ?? 0) > 0;
141
- if (needsKey ? !on("openChests") : !on("openLoot")) continue;
143
+ const recipe = await recipeFor(lootId);
144
+ if (!recipe) continue;
145
+ const isChest = item.type === "CHEST" || /CHEST/i.test(lootId) || /CHEST/i.test(recipe.displayCategories ?? "");
146
+ if (!on("openLoot") || !(isChest ? on("openChests") : on("openMystery"))) continue;
147
+ const slots = Array.isArray(recipe.slots) ? recipe.slots : [];
148
+ const body = [];
149
+ let repeat = count;
150
+ let blocked = false;
151
+ for (const slot of slots) {
152
+ const candidates = Array.isArray(slot?.lootIds) ? slot.lootIds : [];
153
+ const match = candidates.find((id) => (owned.get(id) ?? 0) > 0);
154
+ if (!match) {
155
+ blocked = true;
156
+ break;
157
+ }
158
+ body.push(match);
159
+ repeat = Math.min(repeat, Math.floor((owned.get(match) ?? 0) / Math.max(1, slot.quantity ?? 1)));
160
+ }
161
+ if (blocked || body.length === 0 || repeat <= 0) continue;
162
+ repeat = Math.min(repeat, 25);
142
163
  stamp(`loot:${lootId}`);
143
164
  actions++;
144
- const repeat = Math.min(count, 25);
145
- await sendJson("POST", `/lol-loot/v1/recipes/${encodeURIComponent(open.name)}/craft?repeat=${repeat}`, [lootId]).catch(() => {});
165
+ for (let i = 0; i < body.length; i++) {
166
+ owned.set(body[i], (owned.get(body[i]) ?? 0) - repeat * (slots[i].quantity ?? 1));
167
+ }
168
+ await sendJson("POST", `/lol-loot/v1/recipes/${encodeURIComponent(recipe.recipeName)}/craft?repeat=${repeat}`, body).catch(() => {});
146
169
  }
147
170
  }
148
171
 
@@ -221,7 +244,7 @@ async function runClaims() {
221
244
  await sweepEvents().catch(() => {});
222
245
  await sweepMissions().catch(() => {});
223
246
  await sweepTftPasses().catch(() => {});
224
- if (on("openLoot") || on("openChests")) await sweepLoot().catch(() => {});
247
+ if (on("openLoot") && (on("openMystery") || on("openChests"))) await sweepLoot().catch(() => {});
225
248
  } finally {
226
249
  claimRunning = false;
227
250
  if (claimQueued) {
@@ -279,7 +302,7 @@ function onEnable(utils) {
279
302
  api = utils;
280
303
  active = true;
281
304
  cooldowns.clear();
282
- menus.clear();
305
+ recipes.clear();
283
306
  const claimPush = () => scheduleClaims();
284
307
  const shopPush = () => {
285
308
  if (on("buy1Be")) scheduleShop();
@@ -317,7 +340,7 @@ function onDisable() {
317
340
  }
318
341
  unsubscribes = [];
319
342
  cooldowns.clear();
320
- menus.clear();
343
+ recipes.clear();
321
344
  api = null;
322
345
  }
323
346
 
@@ -349,15 +372,23 @@ export function init() {
349
372
  },
350
373
  {
351
374
  key: "openLoot",
352
- label: "Auto-open loot (mystery icons, ward skins, ...)",
353
- description: "Opens free mystery loot the moment you get it. Never touches items that need a key.",
354
- default: false,
355
- },
356
- {
357
- key: "openChests",
358
- label: "Auto-open chests with keys",
359
- description: "Opens chest + key loot automatically while you have keys.",
375
+ label: "Auto-open loot",
376
+ description: "Master switch for opening loot automatically. Choose what to include below.",
360
377
  default: false,
378
+ children: [
379
+ {
380
+ key: "openMystery",
381
+ label: "Include mystery items",
382
+ description: "Mystery icons, mystery ward skins, mystery emotes, mystery skins and other random-reward items.",
383
+ default: false,
384
+ },
385
+ {
386
+ key: "openChests",
387
+ label: "Include Hextech Chests",
388
+ description: "Hextech Chests, opened with your keys when one is required.",
389
+ default: false,
390
+ },
391
+ ],
361
392
  },
362
393
  ],
363
394
  onEnable,
package/auto-read.js ADDED
@@ -0,0 +1,320 @@
1
+ const PLUGIN_ID = "auto-read";
2
+ const version = "1.1.0";
3
+
4
+ const SWEEP_DEBOUNCE_MS = 500;
5
+ const ITEM_COOLDOWN_MS = 10 * 60 * 1000;
6
+ const BULK_COOLDOWN_MS = 60 * 1000;
7
+
8
+ const MISSIONS_URI = "/lol-missions/v1/missions";
9
+ const MISSIONS_PLAYER_URI = "/lol-missions/v1/player";
10
+ const LOOT_URI = "/lol-loot/v1/player-loot";
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
+ ];
22
+ const RANKED_NOTIF_URI = "/lol-ranked/v1/notifications";
23
+ const EOS_NOTIF_URI = "/lol-ranked/v1/eos-notifications";
24
+ const NOTICE_LIST_URIS = [
25
+ "/player-notifications/v1/notifications",
26
+ "/lol-patch/v1/notifications",
27
+ "/patcher/v1/notifications",
28
+ "/lol-leaver-buster/v1/notifications",
29
+ "/lol-lobby/v2/notifications",
30
+ ];
31
+ const NOTICE_BULK_DELETES = [
32
+ "/lol-gameflow/v1/early-exit-notifications/eog",
33
+ "/lol-gameflow/v1/early-exit-notifications/missions",
34
+ "/lol-statstones/v1/vignette-notifications",
35
+ "/lol-player-behavior/v1/code-of-conduct-notification",
36
+ ];
37
+ const CLASH_BULK_ACKS = [
38
+ "/lol-clash/v1/notifications/acknowledge",
39
+ "/lol-clash/v1/eog-player-update/acknowledge",
40
+ "/lol-clash/v1/game-end/acknowledge",
41
+ ];
42
+ const PIP_STYLE_ID = "auto-read-pips-style";
43
+ const PIP_CSS = ".activity-center__tab_label_notification { display: none !important; }";
44
+
45
+ let api = null;
46
+ let active = false;
47
+ let sweepTimer = null;
48
+ let sweepRunning = false;
49
+ let sweepQueued = false;
50
+ let unsubscribes = [];
51
+ const cooldowns = new Map();
52
+ const markedGrants = new Set();
53
+
54
+ function on(key) {
55
+ try {
56
+ return api?.getOption?.(PLUGIN_ID, `read.${key}`) === true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ function ready(key, wait) {
63
+ return (cooldowns.get(key) ?? 0) + wait <= Date.now();
64
+ }
65
+
66
+ function stamp(key) {
67
+ cooldowns.set(key, Date.now());
68
+ }
69
+
70
+ function getJson(path) {
71
+ return fetch(path, { headers: { Accept: "application/json" } }).then((response) => {
72
+ if (!response.ok) throw new Error(`${path} responded ${response.status}`);
73
+ return response.json();
74
+ });
75
+ }
76
+
77
+ function sendJson(method, path, body) {
78
+ return fetch(path, {
79
+ method,
80
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
81
+ body: body === undefined ? undefined : JSON.stringify(body),
82
+ }).then((response) => {
83
+ if (!response.ok) throw new Error(`${method} ${path} responded ${response.status}`);
84
+ return response.status === 204 ? null : response.json().catch(() => null);
85
+ });
86
+ }
87
+
88
+ async function sweepQuests() {
89
+ const missions = await getJson(MISSIONS_URI);
90
+ if (!Array.isArray(missions)) return;
91
+ const ids = [];
92
+ for (const mission of missions) {
93
+ if (!mission?.id || mission.viewed !== false) continue;
94
+ if (!ready(`mission:${mission.id}`, ITEM_COOLDOWN_MS)) continue;
95
+ stamp(`mission:${mission.id}`);
96
+ ids.push(mission.id);
97
+ }
98
+ for (let i = 0; i < ids.length; i += 100) {
99
+ await sendJson("PUT", MISSIONS_PLAYER_URI, { missionIds: ids.slice(i, i + 100) }).catch(() => {});
100
+ }
101
+ }
102
+
103
+ async function sweepLootMarks() {
104
+ const loot = await getJson(LOOT_URI);
105
+ if (!Array.isArray(loot)) return;
106
+ for (const item of loot) {
107
+ const id = item?.lootId;
108
+ if (!id || item.isNew !== true || !ready(`lootmark:${id}`, ITEM_COOLDOWN_MS)) continue;
109
+ stamp(`lootmark:${id}`);
110
+ await sendJson("DELETE", `${LOOT_URI}/${encodeURIComponent(id)}/new-notification`).catch(() => {});
111
+ }
112
+ }
113
+
114
+ async function sweepClash() {
115
+ const flags = await getJson(CLASH_FLAGS_URI).catch(() => null);
116
+ if (Array.isArray(flags)) {
117
+ for (const flag of flags) {
118
+ if (!flag?.id || flag.status === "ACKNOWLEDGED" || !ready(`clashflag:${flag.id}`, ITEM_COOLDOWN_MS)) continue;
119
+ stamp(`clashflag:${flag.id}`);
120
+ await sendJson("POST", `${CLASH_FLAGS_URI}/${encodeURIComponent(flag.id)}/acknowledge`).catch(() => {});
121
+ }
122
+ }
123
+ if (!ready("clash-bulk", BULK_COOLDOWN_MS)) return;
124
+ stamp("clash-bulk");
125
+ for (const path of CLASH_BULK_ACKS) {
126
+ await sendJson("POST", path).catch(() => {});
127
+ }
128
+ }
129
+
130
+ async function sweepRewards() {
131
+ const grants = await getJson(REWARDS_GRANTS_URI).catch(() => null);
132
+ if (Array.isArray(grants) && grants.length && ready("rewards-grants", BULK_COOLDOWN_MS)) {
133
+ const ids = [];
134
+ for (const grant of grants) {
135
+ const id = grant?.grantId ?? grant?.id;
136
+ if (typeof id !== "string" || !id || markedGrants.has(id)) continue;
137
+ ids.push(id);
138
+ }
139
+ if (ids.length) {
140
+ stamp("rewards-grants");
141
+ for (const id of ids) markedGrants.add(id);
142
+ await sendJson("PATCH", REWARDS_VIEW_URI, ids).catch(() => {
143
+ for (const id of ids) markedGrants.delete(id);
144
+ });
145
+ }
146
+ }
147
+
148
+ const notes = await getJson(LOOT_NOTIF_URI).catch(() => null);
149
+ if (Array.isArray(notes)) {
150
+ for (const note of notes) {
151
+ const id = note?.id ?? note?.lootId;
152
+ if (!id || !ready(`lootnote:${id}`, ITEM_COOLDOWN_MS)) continue;
153
+ stamp(`lootnote:${id}`);
154
+ await sendJson("POST", `${LOOT_NOTIF_URI}/${encodeURIComponent(id)}/acknowledge`).catch(() => {});
155
+ }
156
+ }
157
+
158
+ const mastery = await getJson(MASTERY_NOTIF_URI).catch(() => null);
159
+ if (Number(mastery?.championId ?? 0) > 0 && ready("mastery", ITEM_COOLDOWN_MS)) {
160
+ stamp("mastery");
161
+ await sendJson("POST", MASTERY_ACK_URI).catch(() => {});
162
+ }
163
+
164
+ for (const notice of HONOR_NOTICES) {
165
+ const data = await getJson(notice.uri).catch(() => null);
166
+ const key = `honor:${notice.uri}`;
167
+ if (!data || !notice.present(data) || !ready(key, ITEM_COOLDOWN_MS)) continue;
168
+ stamp(key);
169
+ await sendJson("POST", notice.ack).catch(() => {});
170
+ }
171
+ }
172
+
173
+ async function sweepRanked() {
174
+ for (const uri of [RANKED_NOTIF_URI, EOS_NOTIF_URI]) {
175
+ const list = await getJson(uri).catch(() => null);
176
+ if (!Array.isArray(list)) continue;
177
+ for (const note of list) {
178
+ const id = note?.id ?? note?.notificationId;
179
+ if (id == null || !ready(`ranked:${id}`, ITEM_COOLDOWN_MS)) continue;
180
+ stamp(`ranked:${id}`);
181
+ await sendJson("POST", `${uri}/${encodeURIComponent(id)}/acknowledge`).catch(() => {});
182
+ }
183
+ }
184
+ }
185
+
186
+ async function sweepNotices() {
187
+ for (const uri of NOTICE_LIST_URIS) {
188
+ const list = await getJson(uri).catch(() => null);
189
+ if (!Array.isArray(list)) continue;
190
+ for (const note of list) {
191
+ const id = note?.id ?? note?.notificationId;
192
+ if (id == null || !ready(`notice:${id}`, ITEM_COOLDOWN_MS)) continue;
193
+ stamp(`notice:${id}`);
194
+ await sendJson("DELETE", `${uri}/${encodeURIComponent(id)}`).catch(() => {});
195
+ }
196
+ }
197
+ if (!ready("notice-bulk", BULK_COOLDOWN_MS)) return;
198
+ stamp("notice-bulk");
199
+ for (const path of NOTICE_BULK_DELETES) {
200
+ await sendJson("DELETE", path).catch(() => {});
201
+ }
202
+ }
203
+
204
+ function syncPips() {
205
+ const wanted = active && on("pips");
206
+ let style = document.getElementById(PIP_STYLE_ID);
207
+ if (wanted && !style) {
208
+ style = document.createElement("style");
209
+ style.id = PIP_STYLE_ID;
210
+ style.textContent = PIP_CSS;
211
+ document.head.appendChild(style);
212
+ } else if (!wanted && style) {
213
+ style.remove();
214
+ }
215
+ }
216
+
217
+ function scheduleSweep() {
218
+ if (!active) return;
219
+ clearTimeout(sweepTimer);
220
+ sweepTimer = setTimeout(runSweep, SWEEP_DEBOUNCE_MS);
221
+ }
222
+
223
+ async function runSweep() {
224
+ if (!active) return;
225
+ if (sweepRunning) {
226
+ sweepQueued = true;
227
+ return;
228
+ }
229
+ sweepRunning = true;
230
+ try {
231
+ if (on("quests")) await sweepQuests().catch(() => {});
232
+ if (on("loot")) await sweepLootMarks().catch(() => {});
233
+ if (on("rewards")) await sweepRewards().catch(() => {});
234
+ if (on("clash")) await sweepClash().catch(() => {});
235
+ if (on("ranked")) await sweepRanked().catch(() => {});
236
+ if (on("notices")) await sweepNotices().catch(() => {});
237
+ } finally {
238
+ sweepRunning = false;
239
+ if (sweepQueued) {
240
+ sweepQueued = false;
241
+ scheduleSweep();
242
+ }
243
+ }
244
+ }
245
+
246
+ function onEnable(utils) {
247
+ api = utils;
248
+ active = true;
249
+ 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]) {
251
+ try {
252
+ const off = api.subscribe(uri, scheduleSweep);
253
+ if (off) unsubscribes.push(off);
254
+ } catch {}
255
+ }
256
+ scheduleSweep();
257
+ syncPips();
258
+ }
259
+
260
+ function onDisable() {
261
+ active = false;
262
+ clearTimeout(sweepTimer);
263
+ sweepTimer = null;
264
+ sweepRunning = false;
265
+ sweepQueued = false;
266
+ for (const off of unsubscribes) {
267
+ try {
268
+ off();
269
+ } catch {}
270
+ }
271
+ unsubscribes = [];
272
+ cooldowns.clear();
273
+ markedGrants.clear();
274
+ api = null;
275
+ syncPips();
276
+ }
277
+
278
+ function onOptionChange() {
279
+ if (!active) return;
280
+ scheduleSweep();
281
+ syncPips();
282
+ }
283
+
284
+ export function init() {
285
+ const registerWith = (utils) => {
286
+ utils.registerPlugin({
287
+ id: PLUGIN_ID,
288
+ name: "Auto Read",
289
+ description: "Automatically marks quests, loot, Clash and client notices as read so no badge or dot stays lit.",
290
+ version,
291
+ defaultEnabled: true,
292
+ options: [
293
+ {
294
+ type: "chips",
295
+ key: "read",
296
+ label: "Mark as read",
297
+ description: "Choose what gets marked as read automatically.",
298
+ items: [
299
+ { id: "quests", label: "Quests", description: "Quest and mission badges, including the mission button counter.", default: true },
300
+ { id: "loot", label: "Loot marks", description: "The new mark on freshly earned loot.", default: true },
301
+ { id: "rewards", label: "Rewards", description: "The dot on your avatar for new tokens, titles and banners, plus mastery, honor and loot notifications.", default: true },
302
+ { id: "clash", label: "Clash", description: "Clash tab flags and Clash notifications.", default: true },
303
+ { id: "ranked", label: "Ranked", description: "Ranked, end-of-season and champion mastery notices.", default: true },
304
+ { id: "notices", label: "Notices", description: "Patch, patcher, leaver buster, lobby, behavior and gameflow notices.", default: true },
305
+ { id: "pips", label: "Tab pips", description: "Hides the new pips on activity tabs.", default: true },
306
+ ],
307
+ },
308
+ ],
309
+ onEnable,
310
+ onDisable,
311
+ onOptionChange,
312
+ });
313
+ };
314
+
315
+ if (window.PerfectionistUtils) {
316
+ registerWith(window.PerfectionistUtils);
317
+ return;
318
+ }
319
+ addEventListener("perfectionist:utils-ready", (e) => registerWith(e.detail ?? window.PerfectionistUtils), { once: true });
320
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perfectionist-plugins",
3
- "version": "1.4.1",
3
+ "version": "1.8.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",
@@ -10,14 +10,16 @@
10
10
  "./ban-blocker.js": "./ban-blocker.js",
11
11
  "./hide-unowned-skins.js": "./hide-unowned-skins.js",
12
12
  "./unlock-profile-skins.js": "./unlock-profile-skins.js",
13
- "./auto-claimer.js": "./auto-claimer.js"
13
+ "./auto-claimer.js": "./auto-claimer.js",
14
+ "./auto-read.js": "./auto-read.js"
14
15
  },
15
16
  "files": [
16
17
  "perfectionist-utils.js",
17
18
  "ban-blocker.js",
18
19
  "hide-unowned-skins.js",
19
20
  "unlock-profile-skins.js",
20
- "auto-claimer.js"
21
+ "auto-claimer.js",
22
+ "auto-read.js"
21
23
  ],
22
24
  "repository": {
23
25
  "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.2.0";
9
+ export const version = "1.7.0";
10
10
 
11
11
  const PKG = "perfectionist-plugins";
12
12
  const CDN = "https://cdn.jsdelivr.net/npm";
@@ -47,13 +47,21 @@ const BUNDLED_MODULES = [
47
47
  id: "auto-claimer",
48
48
  file: "auto-claimer.js",
49
49
  name: "Auto Claimer",
50
- version: "1.0.0",
50
+ version: "1.2.0",
51
51
  description: "Automatically claims battle pass, event and mission rewards the moment they become claimable.",
52
52
  },
53
+ {
54
+ id: "auto-read",
55
+ file: "auto-read.js",
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.",
59
+ },
53
60
  ];
54
61
 
55
62
  const STYLE_CSS = `
56
- #${PANEL_ID} { padding: 12px 24px 24px 8px; box-sizing: border-box; color: #f0e6d2; font-family: "LoL Body", Arial, "Helvetica Neue", Helvetica, sans-serif; }
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%; }
57
65
  #${PANEL_ID} .perf-card { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px;
58
66
  padding: 16px 18px; margin-bottom: 12px; box-sizing: border-box;
59
67
  background: #010a13; border: 1px solid #785a28; }
@@ -88,6 +96,30 @@ const STYLE_CSS = `
88
96
  #${PANEL_ID} .perf-opt { display: flex; align-items: center; gap: 9px; cursor: pointer; }
89
97
  #${PANEL_ID} .perf-opt .perf-opt-label { font-size: 11px; letter-spacing: 0.3px; color: #a09b8c; }
90
98
  #${PANEL_ID} .perf-opt:hover .perf-opt-label { color: #f0e6d2; }
99
+ #${PANEL_ID} .perf-opt-group { margin-top: 2px; }
100
+ #${PANEL_ID} .perf-opt-head { display: flex; align-items: center; gap: 9px; }
101
+ #${PANEL_ID} .perf-caret { flex: 0 0 auto; width: 28px; height: 28px; cursor: pointer; display: flex; align-items: center; justify-content: center;
102
+ border: 1px solid transparent; background: transparent; }
103
+ #${PANEL_ID} .perf-caret:hover { border-color: #785a28; background: #0a1428; }
104
+ #${PANEL_ID} .perf-caret::after { content: ""; width: 12px; height: 12px; transform-origin: center;
105
+ background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath d='M2 1l4 3-4 3z' fill='%23c89b3c'/%3E%3C/svg%3E") center / 12px 12px no-repeat;
106
+ transition: transform 0.15s; }
107
+ #${PANEL_ID} .perf-caret:hover::after { filter: brightness(1.4); }
108
+ #${PANEL_ID} .perf-caret--open::after { transform: rotate(90deg); }
109
+ #${PANEL_ID} .perf-opt-children { margin: 8px 0 2px 25px; padding-left: 12px; display: flex; flex-direction: column; gap: 8px;
110
+ border-left: 1px solid #785a28; }
111
+ #${PANEL_ID} .perf-toggle input:disabled + .perf-box,
112
+ #${PANEL_ID} .perf-toggle input:disabled ~ .perf-opt-label { opacity: 0.45; }
113
+ #${PANEL_ID} .perf-toggle input:disabled ~ .perf-opt-label { cursor: default; }
114
+ #${PANEL_ID} .perf-chip-block { margin-top: 11px; }
115
+ #${PANEL_ID} .perf-chips-label { font-size: 10px; font-weight: 700; letter-spacing: 0.6px; text-transform: uppercase; color: #c89b3c; margin-bottom: 7px; }
116
+ #${PANEL_ID} .perf-chips { display: flex; flex-wrap: wrap; gap: 6px; }
117
+ #${PANEL_ID} .perf-chip { font-family: "LoL Body", Arial, "Helvetica Neue", Helvetica, sans-serif; font-size: 10px; font-weight: 700;
118
+ letter-spacing: 0.5px; text-transform: uppercase; color: #a09b8c; background: #0a1428; border: 1px solid #785a28;
119
+ padding: 6px 11px; cursor: pointer; }
120
+ #${PANEL_ID} .perf-chip:hover { color: #f0e6d2; border-color: #c89b3c; }
121
+ #${PANEL_ID} .perf-chip--on { color: #010a13; background: #c89b3c; border-color: #c89b3c; }
122
+ #${PANEL_ID} .perf-chip--on:hover { background: #f0e6d2; }
91
123
 
92
124
  .perf-update { position: fixed; inset: 0; z-index: 100000; display: flex; align-items: center;
93
125
  justify-content: center; background: rgba(1, 10, 19, 0.72); }
@@ -215,14 +247,27 @@ function setPluginEnabled(id, enabled) {
215
247
  return true;
216
248
  }
217
249
 
250
+ function findOption(entry, key) {
251
+ for (const option of entry?.options ?? []) {
252
+ if (option.key === key) return option;
253
+ for (const child of option.children ?? []) {
254
+ if (child.key === key) return child;
255
+ }
256
+ for (const item of option.items ?? []) {
257
+ if (`${option.key}.${item.id}` === key) return item;
258
+ }
259
+ }
260
+ return null;
261
+ }
262
+
218
263
  function getOption(id, key) {
219
264
  const entry = registry.get(id);
220
- const fallback = entry?.options?.find((option) => option.key === key)?.default;
265
+ const fallback = findOption(entry, key)?.default;
221
266
  const stored = state.options?.[id]?.[key];
222
267
  return stored === undefined ? Boolean(fallback) : Boolean(stored);
223
268
  }
224
269
 
225
- function setOption(id, key, value) {
270
+ function setOption(id, key, value, refresh = true) {
226
271
  if (!state.options[id]) state.options[id] = {};
227
272
  state.options[id][key] = Boolean(value);
228
273
  saveState();
@@ -232,7 +277,7 @@ function setOption(id, key, value) {
232
277
  } catch (error) {
233
278
  logError(`${entry?.name ?? id} could not apply the option change.`, error);
234
279
  }
235
- refreshPanel();
280
+ if (refresh) refreshPanel();
236
281
  }
237
282
 
238
283
  function getPlugins() {
@@ -542,20 +587,89 @@ function el(tag, className, text) {
542
587
  return node;
543
588
  }
544
589
 
590
+ const optionGroupState = new Map();
591
+
592
+ function buildOptionRow(mod, option, disabled, onChange) {
593
+ const row = el("label", "perf-opt perf-toggle");
594
+ row.title = option.description ?? "";
595
+ const input = el("input");
596
+ input.type = "checkbox";
597
+ input.checked = getOption(mod.id, option.key);
598
+ input.disabled = Boolean(disabled);
599
+ input.setAttribute("aria-label", `${mod.name}: ${option.label}`);
600
+ input.addEventListener("change", () => {
601
+ if (onChange) onChange(input.checked);
602
+ else setOption(mod.id, option.key, input.checked, false);
603
+ });
604
+ row.append(input, el("span", "perf-box"), el("span", "perf-opt-label", option.label));
605
+ return row;
606
+ }
607
+
608
+ function buildChips(mod, option) {
609
+ const block = el("div", "perf-chip-block");
610
+ block.title = option.description ?? "";
611
+ if (option.label) block.append(el("div", "perf-chips-label", option.label));
612
+ const row = el("div", "perf-chips");
613
+ for (const item of option.items ?? []) {
614
+ const key = `${option.key}.${item.id}`;
615
+ const chip = el("button", "perf-chip", item.label);
616
+ chip.type = "button";
617
+ chip.title = item.description ?? "";
618
+ if (getOption(mod.id, key)) chip.classList.add("perf-chip--on");
619
+ chip.addEventListener("click", () => {
620
+ setOption(mod.id, key, !getOption(mod.id, key), false);
621
+ chip.classList.toggle("perf-chip--on", getOption(mod.id, key));
622
+ });
623
+ row.append(chip);
624
+ }
625
+ block.append(row);
626
+ return block;
627
+ }
628
+
545
629
  function buildOptions(mod) {
546
630
  const options = registry.get(mod.id)?.options ?? [];
547
631
  if (options.length === 0) return null;
548
632
  const wrap = el("div", "perf-opts");
549
633
  for (const option of options) {
550
- const row = el("label", "perf-opt perf-toggle");
551
- row.title = option.description ?? "";
552
- const input = el("input");
553
- input.type = "checkbox";
554
- input.checked = getOption(mod.id, option.key);
555
- input.setAttribute("aria-label", `${mod.name}: ${option.label}`);
556
- input.addEventListener("change", () => setOption(mod.id, option.key, input.checked));
557
- row.append(input, el("span", "perf-box"), el("span", "perf-opt-label", option.label));
558
- wrap.append(row);
634
+ if (option.type === "chips") {
635
+ wrap.append(buildChips(mod, option));
636
+ continue;
637
+ }
638
+ if (!option.children?.length) {
639
+ wrap.append(buildOptionRow(mod, option));
640
+ continue;
641
+ }
642
+ const group = el("div", "perf-opt-group");
643
+ const head = el("div", "perf-opt-head");
644
+ const caret = el("span", "perf-caret");
645
+ caret.setAttribute("role", "button");
646
+ caret.setAttribute("aria-label", `Show ${mod.name} options`);
647
+ const kids = el("div", "perf-opt-children");
648
+ const childInputs = [];
649
+ for (const child of option.children) {
650
+ const childRow = buildOptionRow(mod, child, !getOption(mod.id, option.key));
651
+ childInputs.push(childRow.querySelector("input"));
652
+ kids.append(childRow);
653
+ }
654
+ const master = buildOptionRow(mod, option, false, (checked) => {
655
+ for (const input of childInputs) input.disabled = !checked;
656
+ setOption(mod.id, option.key, checked, false);
657
+ });
658
+ const groupKey = `${mod.id}:${option.key}`;
659
+ const setExpanded = (open) => {
660
+ optionGroupState.set(groupKey, open);
661
+ kids.style.display = open ? "" : "none";
662
+ caret.classList.toggle("perf-caret--open", open);
663
+ };
664
+ setExpanded(optionGroupState.get(groupKey) ?? false);
665
+ caret.addEventListener("click", (event) => {
666
+ event.preventDefault();
667
+ event.stopPropagation();
668
+ setExpanded(!(optionGroupState.get(groupKey) ?? false));
669
+ });
670
+ head.append(master, caret);
671
+ group.append(head, kids);
672
+ wrap.append(group);
559
673
  }
560
674
  return wrap;
561
675
  }
@@ -620,19 +734,37 @@ function buildCard(mod) {
620
734
  function buildPanel() {
621
735
  const panel = el("div");
622
736
  panel.id = PANEL_ID;
737
+ const scroller = el("lol-uikit-scrollable");
738
+ panel.append(scroller);
623
739
 
624
740
  const modules = getAvailableModules();
625
741
  if (modules.length === 0) {
626
- panel.append(el("div", "perf-empty", "No plugins are available."));
742
+ scroller.append(el("div", "perf-empty", "No plugins are available."));
627
743
  return panel;
628
744
  }
629
- for (const mod of modules) panel.append(buildCard(mod));
745
+ for (const mod of modules) scroller.append(buildCard(mod));
630
746
  return panel;
631
747
  }
632
748
 
749
+ function panelScrollTop(panel) {
750
+ return panel?.querySelector("lol-uikit-scrollable")?.scrollTop ?? 0;
751
+ }
752
+
753
+ function applyScrollTop(panel, top) {
754
+ if (!top || !panel) return;
755
+ const host = panel.querySelector("lol-uikit-scrollable");
756
+ if (host) host.scrollTop = top;
757
+ }
758
+
633
759
  function refreshPanel() {
634
760
  const current = document.getElementById(PANEL_ID);
635
- if (current) current.replaceWith(buildPanel());
761
+ if (!current) return;
762
+ const top = panelScrollTop(current);
763
+ const next = buildPanel();
764
+ current.replaceWith(next);
765
+ if (!top) return;
766
+ applyScrollTop(next, top);
767
+ requestAnimationFrame(() => applyScrollTop(next, top));
636
768
  }
637
769
 
638
770
  function setNavActive(activeNav) {