more changes

This commit is contained in:
Guy Sandler 2026-10-25 15:40:10 -07:00
parent 2860aa7c4d
commit 27dba082cb
16 changed files with 229 additions and 59 deletions

View File

@ -611,6 +611,9 @@
"ga_heatmap_undated": {
"message": "{{COUNT}} benotete Aufgabe(n) ohne Fälligkeitsdatum werden nicht angezeigt."
},
"heatmap_exclude_courses": {
"message": "Kurse ausschließen"
},
"ga_line_title": {
"message": "Gesamtnote im Zeitverlauf"
},
@ -1283,8 +1286,8 @@
"preview_unavailable": {
"message": "Keine Vorschau verfügbar"
},
"privacy": {
"message": "Datenschutz"
"dev_tools": {
"message": "Entwickler-Tools"
},
"progress_done": {
"message": "{{DONE}}/{{TOTAL}} erledigt"

View File

@ -1292,8 +1292,8 @@
"preview_unavailable": {
"message": "No preview available"
},
"privacy": {
"message": "Privacy"
"dev_tools": {
"message": "Dev tools"
},
"progress_done": {
"message": "{{DONE}}/{{TOTAL}} done"
@ -2027,6 +2027,9 @@
"heatmap_widget": {
"message": "Heatmap"
},
"heatmap_exclude_courses": {
"message": "Exclude courses"
},
"widget_short_heatmap": {
"message": "Heatmap"
},

View File

@ -611,6 +611,9 @@
"ga_heatmap_undated": {
"message": "{{COUNT}} tarea(s) calificada(s) sin fecha de entrega no mostrada(s)."
},
"heatmap_exclude_courses": {
"message": "Excluir cursos"
},
"ga_line_title": {
"message": "Nota general a lo largo del tiempo"
},
@ -1283,8 +1286,8 @@
"preview_unavailable": {
"message": "No hay vista previa disponible"
},
"privacy": {
"message": "Privacidad"
"dev_tools": {
"message": "Herramientas de desarrollo"
},
"progress_done": {
"message": "{{DONE}}/{{TOTAL}} hechas"

View File

@ -611,6 +611,9 @@
"ga_heatmap_undated": {
"message": "{{COUNT}} devoir(s) noté(s) sans date d'échéance non affiché(s)."
},
"heatmap_exclude_courses": {
"message": "Exclure des cours"
},
"ga_line_title": {
"message": "Note globale au fil du temps"
},
@ -1283,8 +1286,8 @@
"preview_unavailable": {
"message": "Aucun aperçu disponible"
},
"privacy": {
"message": "Confidentialité"
"dev_tools": {
"message": "Outils de développement"
},
"progress_done": {
"message": "{{DONE}}/{{TOTAL}} terminés"

View File

@ -611,6 +611,9 @@
"ga_heatmap_undated": {
"message": "{{COUNT}} consegna/e valutata/e senza data di scadenza non mostrata/e."
},
"heatmap_exclude_courses": {
"message": "Escludi corsi"
},
"ga_line_title": {
"message": "Voto complessivo nel tempo"
},
@ -1283,8 +1286,8 @@
"preview_unavailable": {
"message": "Nessuna anteprima disponibile"
},
"privacy": {
"message": "Privacy"
"dev_tools": {
"message": "Strumenti per sviluppatori"
},
"progress_done": {
"message": "{{DONE}}/{{TOTAL}} completati"

View File

@ -611,6 +611,9 @@
"ga_heatmap_undated": {
"message": "期限のない評価済み課題{{COUNT}}件は表示されていません。"
},
"heatmap_exclude_courses": {
"message": "除外するコース"
},
"ga_line_title": {
"message": "総合評価の推移"
},
@ -1283,8 +1286,8 @@
"preview_unavailable": {
"message": "プレビューを利用できません"
},
"privacy": {
"message": "プライバシー"
"dev_tools": {
"message": "開発者ツール"
},
"progress_done": {
"message": "{{DONE}}/{{TOTAL}} 完了"

View File

@ -611,6 +611,9 @@
"ga_heatmap_undated": {
"message": "{{COUNT}} trabalho(s) avaliado(s) sem data de entrega não apresentado(s)."
},
"heatmap_exclude_courses": {
"message": "Excluir disciplinas"
},
"ga_line_title": {
"message": "Nota global ao longo do tempo"
},
@ -1283,8 +1286,8 @@
"preview_unavailable": {
"message": "Pré-visualização indisponível"
},
"privacy": {
"message": "Privacidade"
"dev_tools": {
"message": "Ferramentas de programador"
},
"progress_done": {
"message": "{{DONE}}/{{TOTAL}} concluídos"

View File

@ -611,6 +611,9 @@
"ga_heatmap_undated": {
"message": "{{COUNT}} оценённых заданий без срока не показано."
},
"heatmap_exclude_courses": {
"message": "Исключить курсы"
},
"ga_line_title": {
"message": "Итоговая оценка во времени"
},
@ -1283,8 +1286,8 @@
"preview_unavailable": {
"message": "Предпросмотр недоступен"
},
"privacy": {
"message": "Конфиденциальность"
"dev_tools": {
"message": "Инструменты разработчика"
},
"progress_done": {
"message": "{{DONE}}/{{TOTAL}} выполнено"

View File

@ -611,6 +611,9 @@
"ga_heatmap_undated": {
"message": "{{COUNT}} betygsatt(a) uppgift(er) utan förfallodatum visas inte."
},
"heatmap_exclude_courses": {
"message": "Exkludera kurser"
},
"ga_line_title": {
"message": "Totalt betyg över tid"
},
@ -1283,8 +1286,8 @@
"preview_unavailable": {
"message": "Ingen förhandsvisning tillgänglig"
},
"privacy": {
"message": "Integritet"
"dev_tools": {
"message": "Utvecklarverktyg"
},
"progress_done": {
"message": "{{DONE}}/{{TOTAL}} klara"

View File

@ -611,6 +611,9 @@
"ga_heatmap_undated": {
"message": "{{COUNT}} 项有评分但无截止日期的作业未显示。"
},
"heatmap_exclude_courses": {
"message": "排除课程"
},
"ga_line_title": {
"message": "总评成绩变化"
},
@ -1283,8 +1286,8 @@
"preview_unavailable": {
"message": "没有可用的预览"
},
"privacy": {
"message": "隐私"
"dev_tools": {
"message": "开发者工具"
},
"progress_done": {
"message": "{{DONE}}/{{TOTAL}} 已完成"

View File

@ -611,6 +611,9 @@
"ga_heatmap_undated": {
"message": "有 {{COUNT}} 項已評分作業沒有到期日,因此未顯示。"
},
"heatmap_exclude_courses": {
"message": "排除課程"
},
"ga_line_title": {
"message": "總成績變化"
},
@ -1283,8 +1286,8 @@
"preview_unavailable": {
"message": "無法提供預覽"
},
"privacy": {
"message": "隱私"
"dev_tools": {
"message": "開發人員工具"
},
"progress_done": {
"message": "已完成 {{DONE}}/{{TOTAL}}"

View File

@ -91,16 +91,22 @@ h2 {margin-top: 20px;font-weight:600}
.custom-tf-row input {flex:none;width:58px;font-size:12px;background:var(--inputbg);border:none;padding:6px;color:#e2e2e2;border-radius:6px;font-family:inherit;text-align:right;box-sizing:border-box;color-scheme:dark;}
.custom-tf-row input::-webkit-outer-spin-button, .custom-tf-row input::-webkit-inner-spin-button {opacity:1;}
.todo-more-options {margin-top: 7px;}
.celebration-multiselect {position:relative;}
.celebration-multiselect-btn {display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:12px;background:var(--inputbg);color:#e2e2e2;border:none;border-radius:7px;padding:6px 8px;font-family:inherit;width:100%;box-sizing:border-box;margin-top:2px;margin-bottom:6px;cursor:pointer;text-align:left;}
.celebration-multiselect-btn svg {flex:none;transition:transform .15s ease;}
.celebration-multiselect.open .celebration-multiselect-btn svg {transform:rotate(-180deg);}
.celebration-multiselect-menu {display:none;position:absolute;top:calc(100% + 4px);left:0;right:0;z-index:60;background:#1e1e1e;border-radius:7px;padding:5px 0;max-height:180px;overflow-y:auto;box-shadow:0 6px 18px rgba(0,0,0,.45);}
.celebration-multiselect.open .celebration-multiselect-menu {display:block;}
.celebration-multiselect.flip-up .celebration-multiselect-menu {top:auto;bottom:calc(100% + 4px);}
.celebration-option {display:flex;align-items:center;gap:7px;padding:5px 10px;font-size:12px;color:#e2e2e2;cursor:pointer;white-space:nowrap;user-select:none;}
.celebration-option:hover {background:#2e2e2e;color:#fff;}
.celebration-option input {accent-color:#56caf0;cursor:pointer;margin:0;}
/* Checkbox dropdown shared by todo celebrations and the heatmap's course filter. */
.cr-multiselect {position:relative;}
.cr-multiselect-btn {display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:12px;background:var(--inputbg);color:#e2e2e2;border:none;border-radius:7px;padding:6px 8px;font-family:inherit;width:100%;box-sizing:border-box;margin-top:2px;margin-bottom:6px;cursor:pointer;text-align:left;}
/* the current selection stays on one line; the menu is where long names wrap */
.cr-multiselect-btn > span {min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.cr-multiselect-btn svg {flex:none;transition:transform .15s ease;}
.cr-multiselect.open .cr-multiselect-btn svg {transform:rotate(-180deg);}
/* grows past a narrow trigger (widget cards) instead of clipping the labels */
.cr-multiselect-menu {display:none;position:absolute;top:calc(100% + 4px);left:0;right:auto;z-index:60;background:#1e1e1e;border-radius:7px;padding:5px 0;max-height:200px;overflow-y:auto;overflow-x:hidden;box-shadow:0 6px 18px rgba(0,0,0,.45);box-sizing:border-box;width:max-content;min-width:100%;max-width:min(320px, calc(100vw - 32px));}
.cr-multiselect.open .cr-multiselect-menu {display:block;}
.cr-multiselect.flip-up .cr-multiselect-menu {top:auto;bottom:calc(100% + 4px);}
.cr-multiselect-option {display:flex;align-items:flex-start;gap:7px;padding:5px 10px;font-size:12px;line-height:1.3;color:#e2e2e2;cursor:pointer;user-select:none;}
.cr-multiselect-option:hover {background:#2e2e2e;color:#fff;}
.cr-multiselect-option span {overflow-wrap:anywhere;}
.cr-multiselect-option input {accent-color:#56caf0;cursor:pointer;margin:1px 0 0;}
.cr-multiselect-empty {padding:6px 10px;font-size:12px;line-height:1.3;color:#9aa3ab;}
#todo_celebration_amount {font-size:12px;background:var(--inputbg);color:#e2e2e2;border:none;border-radius:7px;padding:6px 8px;font-family:inherit;width:100%;box-sizing:border-box;margin-top:2px;margin-bottom:6px;color-scheme:dark;}
#todo_celebration_amount option {background:#1e1e1e;color:#e2e2e2;}
#todo_celebration_amount option:checked,

View File

@ -349,12 +349,12 @@
</div>
<div class="sub-option celebration-sub" id="celebration-type-row" style="margin-top:6px;flex-direction:column;align-items:stretch;gap:4px;">
<span class="sub-text" data-i18n="celebration_type">Celebration type</span>
<div class="celebration-multiselect" id="todo_celebrations">
<button type="button" class="celebration-multiselect-btn" id="todo_celebrations_btn" aria-haspopup="listbox" aria-expanded="false">
<div class="cr-multiselect" id="todo_celebrations">
<button type="button" class="cr-multiselect-btn" id="todo_celebrations_btn" aria-haspopup="listbox" aria-expanded="false">
<span id="todo_celebrations_label" data-i18n="celebration_confetti">Confetti</span>
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6 9l6 6 6-6" /></svg>
</button>
<div class="celebration-multiselect-menu" id="todo_celebrations_menu" role="listbox" aria-multiselectable="true"></div>
<div class="cr-multiselect-menu" id="todo_celebrations_menu" role="listbox" aria-multiselectable="true"></div>
</div>
</div>
<div class="sub-option celebration-sub" id="celebration-amount-row" style="flex-direction:column;align-items:stretch;gap:4px;">
@ -1417,6 +1417,17 @@
<input type="number" id="heatmap_height" class="card-input" min="0" max="2000" data-i18n-placeholder="auto" placeholder="auto" style="width:92px;margin:0;">
<span class="sub-text">px</span>
</div>
<!-- Course filter: ticked courses are left out of the grid. -->
<div class="sub-option" style="margin-top:6px;flex-direction:column;align-items:stretch;gap:4px;">
<span class="sub-text" data-i18n="heatmap_exclude_courses">Exclude courses</span>
<div class="cr-multiselect" id="heatmap_excluded">
<button type="button" class="cr-multiselect-btn" id="heatmap_excluded_btn" aria-haspopup="listbox" aria-expanded="false">
<span id="heatmap_excluded_label" data-i18n="celebration_none">None</span>
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6 9l6 6 6-6" /></svg>
</button>
<div class="cr-multiselect-menu" id="heatmap_excluded_menu" role="listbox" aria-multiselectable="true"></div>
</div>
</div>
</div>
</div>
</div>
@ -1571,7 +1582,7 @@
<p class="sub-text" style="margin:0;font-size:11px;opacity:0.85;">Email is still available at <strong>sandlerguy5@gmail.com</strong> for private matters.</p>
</div>
<div class="option-container">
<h3 class="header-small" data-i18n="privacy">Privacy</h3>
<h3 class="header-small" data-i18n="dev_tools">Dev tools</h3>
<div class="option" id="hide_personal_details">
<input type="radio" id="off" name="hide_personal_details">
<input type="radio" id="on" name="hide_personal_details">

View File

@ -58,6 +58,7 @@ chrome.runtime.onInstalled.addListener(function () {
"quick_links_position": "cards_end",
"heatmap": false,
"heatmap_position": "below",
"heatmap_excluded_courses": [],
"widget_top_height": 0,
"widget_bottom_height": 0,
"widget_row_height": 0,

View File

@ -1128,6 +1128,13 @@ function applyOptionsChanges(changes) {
loadHeatmapWidget();
refreshCardGridLayout();
break;
case "heatmap_excluded_courses":
// Rebuild from the cache (courses drop out immediately) and refetch the rest.
crHeatmapData = null;
crHeatmapLoadedAt = 0;
crHeatmapStarted = false;
crHeatmapEnsureData();
break;
case "quick_links_font_size":
case "quick_links_icon_size":
document.querySelectorAll(".canvasrefined-quick-links").forEach(quickLinksApplySizes);
@ -8682,17 +8689,26 @@ async function crHeatmapFetchCourse(course) {
return out;
}
// Courses ticked in the widget's course filter (stored as ids; may be numbers).
function crHeatmapExcludedIds() {
const raw = options.heatmap_excluded_courses;
return new Set(Array.isArray(raw) ? raw.map(String) : []);
}
async function crHeatmapFetchAll() {
if (crHeatmapLoading) return;
crHeatmapLoading = true;
crHeatmapRenderAll();
try {
const courses = await gsFetchCourseList();
// Excluded courses are skipped here, so they cost no requests either.
const excluded = crHeatmapExcludedIds();
const items = [];
let next = 0;
const worker = async () => {
while (next < courses.length) {
const course = courses[next++];
if (excluded.has(String(course.id))) continue;
try {
const list = await crHeatmapFetchCourse(course);
for (const item of list) items.push(item);
@ -8742,6 +8758,9 @@ function crHeatmapEnsureData() {
function crHeatmapBuild(items) {
const byDay = new Map();
const range = { grades: { min: null, max: null }, subs: { min: null, max: null } };
// Cached items keep their course id, so a course excluded from the widget drops out
// of the grid right away instead of waiting for the next fetch.
const excluded = crHeatmapExcludedIds();
let maxCount = 0, subTotal = 0, undated = 0;
const cellFor = (d, kind) => {
const key = d.getTime();
@ -8756,6 +8775,7 @@ function crHeatmapBuild(items) {
return cell;
};
for (const item of items) {
if (item.courseId != null && excluded.has(String(item.courseId))) continue;
if (item.score != null && !item.excused && item.points > 0) {
const d = crHeatmapDayStart(item.due);
if (d) {

View File

@ -41,6 +41,7 @@ const syncedSubOptions = [
"daily_quotes_height",
"quick_links_height",
"heatmap_height",
"heatmap_excluded_courses",
"quick_links_font_size",
"quick_links_icon_size",
"auto_dark",
@ -292,6 +293,8 @@ const defaultOptions = {
"daily_quotes_height": 0,
"quick_links_height": 0,
"heatmap_height": 0,
// Courses left out of the heatmap widget (course ids as strings).
"heatmap_excluded_courses": [],
// Quick Links tile label font size in px.
"quick_links_font_size": 12,
// Quick Links tile icon box size in px.
@ -587,19 +590,44 @@ function resolveCelebrations(raw, legacyConfetti) {
return legacyConfetti === false ? [] : ["confetti"];
}
function setupCelebrationsSelect(initial) {
const wrap = document.getElementById("todo_celebrations");
const btn = document.getElementById("todo_celebrations_btn");
const menu = document.getElementById("todo_celebrations_menu");
const label = document.getElementById("todo_celebrations_label");
if (!wrap || !btn || !menu || !label) return;
// Labels are rendered through innerHTML, so escape user-provided text (course names).
function escapePopupHtml(value) {
return String(value === null || value === undefined ? "" : value)
.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
// Checkbox dropdown ("select all that apply") shared by the todo celebrations and the
// heatmap's course filter. Options are { id, label } pairs; every change is handed back
// through onChange so the caller decides what to persist.
function setupPopupMultiSelect({ wrapId, btnId, menuId, labelId, emptyText, onChange }) {
const wrap = document.getElementById(wrapId);
const btn = document.getElementById(btnId);
const menu = document.getElementById(menuId);
const label = document.getElementById(labelId);
if (!wrap || !btn || !menu || !label) return null;
const noneLabel = getMsg("celebration_none") || "None";
let items = [];
let selected = [];
const updateLabel = () => {
if (selected.length === 0) label.textContent = noneLabel;
else if (selected.length === 1) {
const item = items.find(i => i.id === selected[0]);
label.textContent = item ? item.label : noneLabel;
} else {
label.textContent = replaceCountPlaceholder(getMsg("selected_count") || "{{COUNT}} selected", selected.length);
}
};
const renderMenu = () => {
menu.innerHTML = CELEBRATION_TYPES.map(t => `
<label class="celebration-option">
<input type="checkbox" value="${t.id}" ${selected.includes(t.id) ? "checked" : ""}>
<span>${t.label}</span>
if (!items.length) {
menu.innerHTML = `<p class="cr-multiselect-empty">${escapePopupHtml(emptyText || noneLabel)}</p>`;
return;
}
menu.innerHTML = items.map(item => `
<label class="cr-multiselect-option">
<input type="checkbox" value="${escapePopupHtml(item.id)}" ${selected.includes(item.id) ? "checked" : ""}>
<span>${escapePopupHtml(item.label)}</span>
</label>
`).join("");
menu.querySelectorAll("input").forEach(input => {
@ -610,22 +638,10 @@ function setupCelebrationsSelect(initial) {
selected = selected.filter(id => id !== input.value);
}
updateLabel();
chrome.storage.sync.set({ "todo_celebrations": selected });
if (onChange) onChange(selected.slice());
});
});
};
const updateLabel = () => {
if (selected.length === 0) label.textContent = getMsg("celebration_none") || "None";
else if (selected.length === 1) label.textContent = CELEBRATION_TYPES.find(t => t.id === selected[0]) ? (getMsg("celebration_" + selected[0]) || CELEBRATION_TYPES.find(t => t.id === selected[0]).label) : (getMsg("celebration_none") || "None");
else label.textContent = replaceCountPlaceholder((getMsg("selected_count") || "{{COUNT}} selected"), selected.length);
};
// Legacy key: the special handler only passes the new one.
chrome.storage.sync.get("todo_confetti", legacy => {
selected = resolveCelebrations(initial, legacy && legacy["todo_confetti"]);
renderMenu();
updateLabel();
});
btn.addEventListener("click", (e) => {
e.stopPropagation();
@ -645,6 +661,44 @@ function setupCelebrationsSelect(initial) {
btn.setAttribute("aria-expanded", "false");
}
});
return {
// Replacing the option list keeps any still-valid selection.
setItems(list) {
items = Array.isArray(list) ? list : [];
const ids = new Set(items.map(i => i.id));
selected = selected.filter(id => ids.has(id));
renderMenu();
updateLabel();
},
setSelected(ids) {
const valid = new Set(items.map(i => i.id));
selected = (Array.isArray(ids) ? ids : []).map(String).filter(id => !valid.size || valid.has(id));
renderMenu();
updateLabel();
},
getSelected() { return selected.slice(); },
close() {
wrap.classList.remove("open");
btn.setAttribute("aria-expanded", "false");
},
};
}
function setupCelebrationsSelect(initial) {
const control = setupPopupMultiSelect({
wrapId: "todo_celebrations",
btnId: "todo_celebrations_btn",
menuId: "todo_celebrations_menu",
labelId: "todo_celebrations_label",
onChange: (selected) => chrome.storage.sync.set({ "todo_celebrations": selected }),
});
if (!control) return;
control.setItems(CELEBRATION_TYPES.map(t => ({ id: t.id, label: (getMsg("celebration_" + t.id) || t.label) })));
// Legacy key: the special handler only passes the new one.
chrome.storage.sync.get("todo_confetti", legacy => {
control.setSelected(resolveCelebrations(initial, legacy && legacy["todo_confetti"]));
});
}
function setupCelebrationAmount(initial) {
@ -1442,6 +1496,47 @@ function setupWidgetHeightInput(id, initial, legacyKey) {
});
}
// Heatmap course filter: ticked courses are left out of the widget completely.
function setupHeatmapExcludedCourses(initial) {
const control = setupPopupMultiSelect({
wrapId: "heatmap_excluded",
btnId: "heatmap_excluded_btn",
menuId: "heatmap_excluded_menu",
labelId: "heatmap_excluded_label",
emptyText: getMsg("no_course_cards", "Visit your Canvas dashboard to load courses."),
onChange: (selected) => chrome.storage.sync.set({ "heatmap_excluded_courses": selected }),
});
if (!control) return;
// Only the courses still on the dashboard, labelled the way the user knows them
// (custom name, then the Canvas nickname) — same list as the Cards tab.
const renderList = (cards) => {
const entries = Object.entries(cards || {})
.filter(([, card]) => card && card.hidden !== true && card.hide !== true)
.map(([id, card]) => ({
id: String(id),
label: card.name || card.fullName || card.default || card.code || `Course ${id}`,
}))
.sort((a, b) => a.label.localeCompare(b.label));
control.setItems(entries);
};
chrome.storage.sync.get("custom_cards", sync => {
renderList(sync.custom_cards);
// The stored ids only survive setItems if their course is still listed.
control.setSelected(Array.isArray(initial) ? initial : []);
});
// A dashboard tab writes the card list; keep the dropdown in sync when it lands.
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "sync" || !changes["custom_cards"]) return;
if (!document.getElementById("heatmap_excluded")) return;
const selected = control.getSelected();
renderList(changes["custom_cards"].newValue);
control.setSelected(selected);
});
}
// h:mm:ss fields: click to edit a segment, a second digit auto-advances, and
// the value is saved zero-padded on blur. The hours field is optional.
function setupWidgetTimeInput(prefix, initial) {
@ -2103,6 +2198,10 @@ async function setup() {
identifier: "heatmap_height",
setup: (initial) => setupWidgetHeightInput("heatmap_height", initial),
},
{
identifier: "heatmap_excluded_courses",
setup: (initial) => setupHeatmapExcludedCourses(initial),
},
{
identifier: "quick_links_font_size",
setup: (initial) => setupRangeSlider("quick_links_font_size", "quickLinksFontSizeSlider", "quickLinksFontSizeValue", null, 12, 24, "px", initial, 8),