perfectionist-plugins 1.4.1 → 1.6.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,263 @@
1
+ const PLUGIN_ID = "auto-read";
2
+ const version = "1.0.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 RANKED_NOTIF_URI = "/lol-ranked/v1/notifications";
13
+ const EOS_NOTIF_URI = "/lol-ranked/v1/eos-notifications";
14
+ const NOTICE_LIST_URIS = [
15
+ "/player-notifications/v1/notifications",
16
+ "/lol-patch/v1/notifications",
17
+ "/patcher/v1/notifications",
18
+ "/lol-leaver-buster/v1/notifications",
19
+ "/lol-lobby/v2/notifications",
20
+ ];
21
+ const NOTICE_BULK_DELETES = [
22
+ "/lol-gameflow/v1/early-exit-notifications/eog",
23
+ "/lol-gameflow/v1/early-exit-notifications/missions",
24
+ "/lol-statstones/v1/vignette-notifications",
25
+ "/lol-player-behavior/v1/code-of-conduct-notification",
26
+ ];
27
+ const CLASH_BULK_ACKS = [
28
+ "/lol-clash/v1/notifications/acknowledge",
29
+ "/lol-clash/v1/eog-player-update/acknowledge",
30
+ "/lol-clash/v1/game-end/acknowledge",
31
+ ];
32
+ const PIP_STYLE_ID = "auto-read-pips-style";
33
+ const PIP_CSS = ".activity-center__tab_label_notification { display: none !important; }";
34
+
35
+ let api = null;
36
+ let active = false;
37
+ let sweepTimer = null;
38
+ let sweepRunning = false;
39
+ let sweepQueued = false;
40
+ let unsubscribes = [];
41
+ const cooldowns = new Map();
42
+
43
+ function on(key) {
44
+ try {
45
+ return api?.getOption?.(PLUGIN_ID, `read.${key}`) === true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ function ready(key, wait) {
52
+ return (cooldowns.get(key) ?? 0) + wait <= Date.now();
53
+ }
54
+
55
+ function stamp(key) {
56
+ cooldowns.set(key, Date.now());
57
+ }
58
+
59
+ function getJson(path) {
60
+ return fetch(path, { headers: { Accept: "application/json" } }).then((response) => {
61
+ if (!response.ok) throw new Error(`${path} responded ${response.status}`);
62
+ return response.json();
63
+ });
64
+ }
65
+
66
+ function sendJson(method, path, body) {
67
+ return fetch(path, {
68
+ method,
69
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
70
+ body: body === undefined ? undefined : JSON.stringify(body),
71
+ }).then((response) => {
72
+ if (!response.ok) throw new Error(`${method} ${path} responded ${response.status}`);
73
+ return response.status === 204 ? null : response.json().catch(() => null);
74
+ });
75
+ }
76
+
77
+ async function sweepQuests() {
78
+ const missions = await getJson(MISSIONS_URI);
79
+ if (!Array.isArray(missions)) return;
80
+ const ids = [];
81
+ for (const mission of missions) {
82
+ if (!mission?.id || mission.viewed !== false) continue;
83
+ if (!ready(`mission:${mission.id}`, ITEM_COOLDOWN_MS)) continue;
84
+ stamp(`mission:${mission.id}`);
85
+ ids.push(mission.id);
86
+ }
87
+ for (let i = 0; i < ids.length; i += 100) {
88
+ await sendJson("PUT", MISSIONS_PLAYER_URI, { missionIds: ids.slice(i, i + 100) }).catch(() => {});
89
+ }
90
+ }
91
+
92
+ async function sweepLootMarks() {
93
+ const loot = await getJson(LOOT_URI);
94
+ if (!Array.isArray(loot)) return;
95
+ for (const item of loot) {
96
+ const id = item?.lootId;
97
+ if (!id || item.isNew !== true || !ready(`lootmark:${id}`, ITEM_COOLDOWN_MS)) continue;
98
+ stamp(`lootmark:${id}`);
99
+ await sendJson("DELETE", `${LOOT_URI}/${encodeURIComponent(id)}/new-notification`).catch(() => {});
100
+ }
101
+ }
102
+
103
+ async function sweepClash() {
104
+ const flags = await getJson(CLASH_FLAGS_URI).catch(() => null);
105
+ if (Array.isArray(flags)) {
106
+ for (const flag of flags) {
107
+ if (!flag?.id || flag.status === "ACKNOWLEDGED" || !ready(`clashflag:${flag.id}`, ITEM_COOLDOWN_MS)) continue;
108
+ stamp(`clashflag:${flag.id}`);
109
+ await sendJson("POST", `${CLASH_FLAGS_URI}/${encodeURIComponent(flag.id)}/acknowledge`).catch(() => {});
110
+ }
111
+ }
112
+ if (!ready("clash-bulk", BULK_COOLDOWN_MS)) return;
113
+ stamp("clash-bulk");
114
+ for (const path of CLASH_BULK_ACKS) {
115
+ await sendJson("POST", path).catch(() => {});
116
+ }
117
+ }
118
+
119
+ async function sweepRanked() {
120
+ for (const uri of [RANKED_NOTIF_URI, EOS_NOTIF_URI]) {
121
+ const list = await getJson(uri).catch(() => null);
122
+ if (!Array.isArray(list)) continue;
123
+ for (const note of list) {
124
+ const id = note?.id ?? note?.notificationId;
125
+ if (id == null || !ready(`ranked:${id}`, ITEM_COOLDOWN_MS)) continue;
126
+ stamp(`ranked:${id}`);
127
+ await sendJson("POST", `${uri}/${encodeURIComponent(id)}/acknowledge`).catch(() => {});
128
+ }
129
+ }
130
+ }
131
+
132
+ async function sweepNotices() {
133
+ for (const uri of NOTICE_LIST_URIS) {
134
+ const list = await getJson(uri).catch(() => null);
135
+ if (!Array.isArray(list)) continue;
136
+ for (const note of list) {
137
+ const id = note?.id ?? note?.notificationId;
138
+ if (id == null || !ready(`notice:${id}`, ITEM_COOLDOWN_MS)) continue;
139
+ stamp(`notice:${id}`);
140
+ await sendJson("DELETE", `${uri}/${encodeURIComponent(id)}`).catch(() => {});
141
+ }
142
+ }
143
+ if (!ready("notice-bulk", BULK_COOLDOWN_MS)) return;
144
+ stamp("notice-bulk");
145
+ for (const path of NOTICE_BULK_DELETES) {
146
+ await sendJson("DELETE", path).catch(() => {});
147
+ }
148
+ }
149
+
150
+ function syncPips() {
151
+ const wanted = active && on("pips");
152
+ let style = document.getElementById(PIP_STYLE_ID);
153
+ if (wanted && !style) {
154
+ style = document.createElement("style");
155
+ style.id = PIP_STYLE_ID;
156
+ style.textContent = PIP_CSS;
157
+ document.head.appendChild(style);
158
+ } else if (!wanted && style) {
159
+ style.remove();
160
+ }
161
+ }
162
+
163
+ function scheduleSweep() {
164
+ if (!active) return;
165
+ clearTimeout(sweepTimer);
166
+ sweepTimer = setTimeout(runSweep, SWEEP_DEBOUNCE_MS);
167
+ }
168
+
169
+ async function runSweep() {
170
+ if (!active) return;
171
+ if (sweepRunning) {
172
+ sweepQueued = true;
173
+ return;
174
+ }
175
+ sweepRunning = true;
176
+ try {
177
+ if (on("quests")) await sweepQuests().catch(() => {});
178
+ if (on("loot")) await sweepLootMarks().catch(() => {});
179
+ if (on("clash")) await sweepClash().catch(() => {});
180
+ if (on("ranked")) await sweepRanked().catch(() => {});
181
+ if (on("notices")) await sweepNotices().catch(() => {});
182
+ } finally {
183
+ sweepRunning = false;
184
+ if (sweepQueued) {
185
+ sweepQueued = false;
186
+ scheduleSweep();
187
+ }
188
+ }
189
+ }
190
+
191
+ function onEnable(utils) {
192
+ api = utils;
193
+ active = true;
194
+ cooldowns.clear();
195
+ for (const uri of [MISSIONS_URI, LOOT_URI, CLASH_FLAGS_URI, RANKED_NOTIF_URI, EOS_NOTIF_URI, ...NOTICE_LIST_URIS]) {
196
+ try {
197
+ const off = api.subscribe(uri, scheduleSweep);
198
+ if (off) unsubscribes.push(off);
199
+ } catch {}
200
+ }
201
+ scheduleSweep();
202
+ syncPips();
203
+ }
204
+
205
+ function onDisable() {
206
+ active = false;
207
+ clearTimeout(sweepTimer);
208
+ sweepTimer = null;
209
+ sweepRunning = false;
210
+ sweepQueued = false;
211
+ for (const off of unsubscribes) {
212
+ try {
213
+ off();
214
+ } catch {}
215
+ }
216
+ unsubscribes = [];
217
+ cooldowns.clear();
218
+ api = null;
219
+ syncPips();
220
+ }
221
+
222
+ function onOptionChange() {
223
+ if (!active) return;
224
+ scheduleSweep();
225
+ syncPips();
226
+ }
227
+
228
+ export function init() {
229
+ const registerWith = (utils) => {
230
+ utils.registerPlugin({
231
+ id: PLUGIN_ID,
232
+ name: "Auto Read",
233
+ description: "Automatically marks quests, loot, Clash and client notices as read so no badge or dot stays lit.",
234
+ version,
235
+ defaultEnabled: true,
236
+ options: [
237
+ {
238
+ type: "chips",
239
+ key: "read",
240
+ label: "Mark as read",
241
+ description: "Choose what gets marked as read automatically.",
242
+ items: [
243
+ { id: "quests", label: "Quests", description: "Quest and mission badges, including the mission button counter.", default: true },
244
+ { id: "loot", label: "Loot marks", description: "The new mark on freshly earned loot.", default: true },
245
+ { id: "clash", label: "Clash", description: "Clash tab flags and Clash notifications.", default: true },
246
+ { id: "ranked", label: "Ranked", description: "Ranked, end-of-season and champion mastery notices.", default: true },
247
+ { id: "notices", label: "Notices", description: "Patch, patcher, leaver buster, lobby, behavior and gameflow notices.", default: true },
248
+ { id: "pips", label: "Tab pips", description: "Hides the new pips on activity tabs.", default: true },
249
+ ],
250
+ },
251
+ ],
252
+ onEnable,
253
+ onDisable,
254
+ onOptionChange,
255
+ });
256
+ };
257
+
258
+ if (window.PerfectionistUtils) {
259
+ registerWith(window.PerfectionistUtils);
260
+ return;
261
+ }
262
+ addEventListener("perfectionist:utils-ready", (e) => registerWith(e.detail ?? window.PerfectionistUtils), { once: true });
263
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perfectionist-plugins",
3
- "version": "1.4.1",
3
+ "version": "1.6.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.6.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.0.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,9 +247,22 @@ 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
  }
@@ -542,20 +587,86 @@ 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);
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", () => setOption(mod.id, key, !getOption(mod.id, key)));
620
+ row.append(chip);
621
+ }
622
+ block.append(row);
623
+ return block;
624
+ }
625
+
545
626
  function buildOptions(mod) {
546
627
  const options = registry.get(mod.id)?.options ?? [];
547
628
  if (options.length === 0) return null;
548
629
  const wrap = el("div", "perf-opts");
549
630
  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);
631
+ if (option.type === "chips") {
632
+ wrap.append(buildChips(mod, option));
633
+ continue;
634
+ }
635
+ if (!option.children?.length) {
636
+ wrap.append(buildOptionRow(mod, option));
637
+ continue;
638
+ }
639
+ const group = el("div", "perf-opt-group");
640
+ const head = el("div", "perf-opt-head");
641
+ const caret = el("span", "perf-caret");
642
+ caret.setAttribute("role", "button");
643
+ caret.setAttribute("aria-label", `Show ${mod.name} options`);
644
+ const kids = el("div", "perf-opt-children");
645
+ const childInputs = [];
646
+ for (const child of option.children) {
647
+ const childRow = buildOptionRow(mod, child, !getOption(mod.id, option.key));
648
+ childInputs.push(childRow.querySelector("input"));
649
+ kids.append(childRow);
650
+ }
651
+ const master = buildOptionRow(mod, option, false, (checked) => {
652
+ for (const input of childInputs) input.disabled = !checked;
653
+ setOption(mod.id, option.key, checked);
654
+ });
655
+ const groupKey = `${mod.id}:${option.key}`;
656
+ const setExpanded = (open) => {
657
+ optionGroupState.set(groupKey, open);
658
+ kids.style.display = open ? "" : "none";
659
+ caret.classList.toggle("perf-caret--open", open);
660
+ };
661
+ setExpanded(optionGroupState.get(groupKey) ?? false);
662
+ caret.addEventListener("click", (event) => {
663
+ event.preventDefault();
664
+ event.stopPropagation();
665
+ setExpanded(!(optionGroupState.get(groupKey) ?? false));
666
+ });
667
+ head.append(master, caret);
668
+ group.append(head, kids);
669
+ wrap.append(group);
559
670
  }
560
671
  return wrap;
561
672
  }
@@ -620,13 +731,15 @@ function buildCard(mod) {
620
731
  function buildPanel() {
621
732
  const panel = el("div");
622
733
  panel.id = PANEL_ID;
734
+ const scroller = el("lol-uikit-scrollable");
735
+ panel.append(scroller);
623
736
 
624
737
  const modules = getAvailableModules();
625
738
  if (modules.length === 0) {
626
- panel.append(el("div", "perf-empty", "No plugins are available."));
739
+ scroller.append(el("div", "perf-empty", "No plugins are available."));
627
740
  return panel;
628
741
  }
629
- for (const mod of modules) panel.append(buildCard(mod));
742
+ for (const mod of modules) scroller.append(buildCard(mod));
630
743
  return panel;
631
744
  }
632
745