From 1dcb29ec4e916811bd906c3c1d9c601bed320bdd Mon Sep 17 00:00:00 2001
From: "Refone.Mac" <710196926@qq.com>
Date: Fri, 25 Sep 2026 16:15:40 +0800
Subject: [PATCH 1/2] feat(sidebar): drag to resize the left sidebar
---
index.html | 26 +++++++++++++++
src/main.ts | 73 +++++++++++++++++++++++++++++++++++++++++
src/preferences.test.ts | 44 +++++++++++++++++++++++++
src/preferences.ts | 22 +++++++++++++
4 files changed, 165 insertions(+)
diff --git a/index.html b/index.html
index 60dd87d..25e7d87 100644
--- a/index.html
+++ b/index.html
@@ -270,6 +270,30 @@
overflow: hidden;
}
+ /* Drag handle that resizes the left sidebar. It sits between the sidebar's
+ right border and the content area as a narrow invisible strip that lights
+ up on hover/while dragging. */
+ #sidebar-resizer {
+ flex: 0 0 6px;
+ cursor: col-resize;
+ background: transparent;
+ border-left: 1px solid transparent;
+ transition:
+ background 0.15s,
+ border-color 0.15s;
+ }
+
+ #sidebar-resizer:hover,
+ body.resizing-sidebar #sidebar-resizer {
+ background: var(--accent-light);
+ border-left-color: var(--accent);
+ }
+
+ body.resizing-sidebar {
+ cursor: col-resize;
+ user-select: none;
+ }
+
#sidebar-header {
padding: 16px;
border-bottom: 1px solid var(--border);
@@ -2052,6 +2076,8 @@
Markdown Viewer
diff --git a/src/main.ts b/src/main.ts
index 424bbcd..e72adc6 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -27,24 +27,30 @@ import {
BODY_FONT_KEY,
CODE_FONT_KEY,
CUSTOM_FONT_SIZE_KEY,
+ DEFAULT_SIDEBAR_WIDTH,
FONTSIZE_KEY,
FONT_WEIGHT_KEY,
MAX_CUSTOM_FONT_SIZE,
+ MAX_SIDEBAR_WIDTH,
MIN_CUSTOM_FONT_SIZE,
+ MIN_SIDEBAR_WIDTH,
FINDER_OPEN_KEY,
OUTLINE_PREF_KEY,
+ SIDEBAR_WIDTH_KEY,
applyBodyFontToDOM,
applyCodeFontToDOM,
applyCustomFontSizeToDOM,
applyFontSizeToDOM,
applyFontWeightToDOM,
applyOutlinePrefToDOM,
+ applySidebarWidthToDOM,
resolveInitialCustomFontSize,
resolveInitialFontFamily,
resolveInitialFontSize,
resolveInitialFinderOpenPref,
resolveInitialFontWeight,
resolveInitialOutlinePref,
+ resolveInitialSidebarWidth,
setSegmentActive,
type FinderOpenPref,
type FontSize,
@@ -158,6 +164,9 @@ async function initTheme(): Promise {
const savedOutline = resolveInitialOutlinePref(
await store.get(OUTLINE_PREF_KEY)
);
+ const savedSidebarWidth = resolveInitialSidebarWidth(
+ await store.get(SIDEBAR_WIDTH_KEY)
+ );
const storeCustoms = sanitizeCustomThemes(
await store.get(CUSTOM_THEMES_KEY)
@@ -192,6 +201,7 @@ async function initTheme(): Promise {
applyActiveTheme();
applyFontSize(savedFontSize);
applyOutlinePref(savedOutline);
+ applySidebarWidthToDOM(document.documentElement, savedSidebarWidth);
applyBodyFontToDOM(document.documentElement, currentBodyFont);
applyCodeFontToDOM(document.documentElement, currentCodeFont);
applyFontWeightToDOM(document.documentElement, currentFontWeight);
@@ -457,6 +467,11 @@ const searchOptWholeWord = document.getElementById(
"search-opt-wholeword"
) as HTMLInputElement;
+const sidebarEl = document.getElementById("sidebar") as HTMLDivElement;
+const sidebarResizer = document.getElementById(
+ "sidebar-resizer"
+) as HTMLDivElement;
+
const appWindow = getCurrentWindow();
// Keeps the native window title in sync with what the window shows. The title is
@@ -666,6 +681,63 @@ function initSearch(): void {
document.addEventListener("keydown", handleGlobalSearchShortcut);
}
+// --- Sidebar resizing ---
+
+let sidebarDragging = false;
+let sidebarDragStartX = 0;
+let sidebarDragStartWidth = 0;
+
+function currentSidebarWidth(): number {
+ return sidebarEl.getBoundingClientRect().width;
+}
+
+function setSidebarWidth(px: number): void {
+ const clamped = Math.min(
+ MAX_SIDEBAR_WIDTH,
+ Math.max(MIN_SIDEBAR_WIDTH, px)
+ );
+ applySidebarWidthToDOM(document.documentElement, clamped);
+}
+
+function initSidebarResizer(): void {
+ sidebarResizer.addEventListener("pointerdown", (e) => {
+ e.preventDefault();
+ // Capture so moves/up keep firing even if the cursor leaves the window.
+ sidebarResizer.setPointerCapture(e.pointerId);
+ sidebarDragging = true;
+ sidebarDragStartX = e.clientX;
+ sidebarDragStartWidth = currentSidebarWidth();
+ document.body.classList.add("resizing-sidebar");
+ });
+
+ sidebarResizer.addEventListener("pointermove", (e) => {
+ if (!sidebarDragging) return;
+ setSidebarWidth(sidebarDragStartWidth + (e.clientX - sidebarDragStartX));
+ });
+
+ function endSidebarDrag(): void {
+ if (!sidebarDragging) return;
+ sidebarDragging = false;
+ document.body.classList.remove("resizing-sidebar");
+ }
+
+ sidebarResizer.addEventListener("pointerup", async (e) => {
+ if (!sidebarDragging) return;
+ sidebarResizer.releasePointerCapture(e.pointerId);
+ endSidebarDrag();
+ await savePref(SIDEBAR_WIDTH_KEY, Math.round(currentSidebarWidth()));
+ });
+
+ sidebarResizer.addEventListener("pointercancel", () => {
+ endSidebarDrag();
+ });
+
+ sidebarResizer.addEventListener("dblclick", async () => {
+ setSidebarWidth(DEFAULT_SIDEBAR_WIDTH);
+ await savePref(SIDEBAR_WIDTH_KEY, DEFAULT_SIDEBAR_WIDTH);
+ });
+}
+
// --- Preferences modal ---
const prefsBackdrop = document.getElementById(
@@ -1519,6 +1591,7 @@ async function init(): Promise {
await initTheme();
initSearch();
initPreferences();
+ initSidebarResizer();
// Runtime opens (hot-start file association, "Open With", CLI events)
appWindow.listen("open-folder", (event) => {
diff --git a/src/preferences.test.ts b/src/preferences.test.ts
index 73d37da..c88cc4f 100644
--- a/src/preferences.test.ts
+++ b/src/preferences.test.ts
@@ -23,10 +23,16 @@ import {
applyCodeFontToDOM,
applyFontWeightToDOM,
applyCustomFontSizeToDOM,
+ applySidebarWidthToDOM,
+ resolveInitialSidebarWidth,
+ isValidSidebarWidth,
effectiveFontSizePx,
PRESET_FONT_SIZE_PX,
MIN_CUSTOM_FONT_SIZE,
MAX_CUSTOM_FONT_SIZE,
+ MIN_SIDEBAR_WIDTH,
+ MAX_SIDEBAR_WIDTH,
+ DEFAULT_SIDEBAR_WIDTH,
} from "./preferences";
describe("resolveDark", () => {
@@ -359,3 +365,41 @@ describe("resolveInitialFinderOpenPref", () => {
expect(isFinderOpenPref("")).toBe(false);
});
});
+
+describe("sidebar width", () => {
+ it("isValidSidebarWidth accepts values inside the range", () => {
+ expect(isValidSidebarWidth(MIN_SIDEBAR_WIDTH)).toBe(true);
+ expect(isValidSidebarWidth(320)).toBe(true);
+ expect(isValidSidebarWidth(MAX_SIDEBAR_WIDTH)).toBe(true);
+ });
+
+ it("isValidSidebarWidth rejects out-of-range or non-numbers", () => {
+ expect(isValidSidebarWidth(MIN_SIDEBAR_WIDTH - 1)).toBe(false);
+ expect(isValidSidebarWidth(MAX_SIDEBAR_WIDTH + 1)).toBe(false);
+ expect(isValidSidebarWidth(NaN)).toBe(false);
+ expect(isValidSidebarWidth(Infinity)).toBe(false);
+ expect(isValidSidebarWidth("320")).toBe(false);
+ expect(isValidSidebarWidth(null)).toBe(false);
+ expect(isValidSidebarWidth(undefined)).toBe(false);
+ });
+
+ it("resolveInitialSidebarWidth keeps a valid value", () => {
+ expect(resolveInitialSidebarWidth(300)).toBe(300);
+ expect(resolveInitialSidebarWidth(MIN_SIDEBAR_WIDTH)).toBe(
+ MIN_SIDEBAR_WIDTH
+ );
+ });
+
+ it("resolveInitialSidebarWidth falls back to the default for bad values", () => {
+ expect(resolveInitialSidebarWidth(null)).toBe(DEFAULT_SIDEBAR_WIDTH);
+ expect(resolveInitialSidebarWidth("300")).toBe(DEFAULT_SIDEBAR_WIDTH);
+ expect(resolveInitialSidebarWidth(10)).toBe(DEFAULT_SIDEBAR_WIDTH);
+ expect(resolveInitialSidebarWidth(10000)).toBe(DEFAULT_SIDEBAR_WIDTH);
+ });
+
+ it("applySidebarWidthToDOM sets --sidebar-width in px", () => {
+ const root = document.createElement("html");
+ applySidebarWidthToDOM(root, 420);
+ expect(root.style.getPropertyValue("--sidebar-width")).toBe("420px");
+ });
+});
diff --git a/src/preferences.ts b/src/preferences.ts
index 7f3cf47..ab5eb67 100644
--- a/src/preferences.ts
+++ b/src/preferences.ts
@@ -35,6 +35,11 @@ export const CODE_FONT_KEY = "codeFontFamily";
export const FONT_WEIGHT_KEY = "fontWeight";
export const CUSTOM_FONT_SIZE_KEY = "customFontSizePx";
+export const SIDEBAR_WIDTH_KEY = "sidebarWidth";
+export const MIN_SIDEBAR_WIDTH = 160;
+export const MAX_SIDEBAR_WIDTH = 640;
+export const DEFAULT_SIDEBAR_WIDTH = 260;
+
export const FONT_WEIGHT_VALUES = [
"300",
"400",
@@ -172,6 +177,23 @@ export function resolveInitialOutlinePref(value: unknown): OutlinePref {
return isOutlinePref(value) ? value : "auto";
}
+export function isValidSidebarWidth(value: unknown): value is number {
+ return (
+ typeof value === "number" &&
+ Number.isFinite(value) &&
+ value >= MIN_SIDEBAR_WIDTH &&
+ value <= MAX_SIDEBAR_WIDTH
+ );
+}
+
+export function resolveInitialSidebarWidth(value: unknown): number {
+ return isValidSidebarWidth(value) ? value : DEFAULT_SIDEBAR_WIDTH;
+}
+
+export function applySidebarWidthToDOM(root: HTMLElement, px: number): void {
+ root.style.setProperty("--sidebar-width", `${px}px`);
+}
+
export function setSegmentActive(group: HTMLElement, value: string): void {
for (const btn of group.querySelectorAll("button")) {
btn.classList.toggle("active", btn.dataset.value === value);
From 2a176495921cc05e36096cb8a04b759c466251bf Mon Sep 17 00:00:00 2001
From: "Refone.Mac" <710196926@qq.com>
Date: Fri, 25 Sep 2026 17:02:55 +0800
Subject: [PATCH 2/2] feat(outline): drag to resize the right outline
---
index.html | 34 ++++++++++++++++++-
src/main.ts | 74 +++++++++++++++++++++++++++++++++++++++++
src/preferences.test.ts | 44 ++++++++++++++++++++++++
src/preferences.ts | 22 ++++++++++++
4 files changed, 173 insertions(+), 1 deletion(-)
diff --git a/index.html b/index.html
index 25e7d87..e19b8f0 100644
--- a/index.html
+++ b/index.html
@@ -294,6 +294,31 @@
user-select: none;
}
+ /* Drag handle that resizes the right outline panel. Mirrors the sidebar
+ resizer: it sits just left of the outline's border and is hidden whenever
+ the outline itself is hidden. */
+ #outline-resizer {
+ flex: 0 0 6px;
+ cursor: col-resize;
+ background: transparent;
+ border-right: 1px solid transparent;
+ transition:
+ background 0.15s,
+ border-color 0.15s;
+ display: none;
+ }
+
+ #outline-resizer:hover,
+ body.resizing-outline #outline-resizer {
+ background: var(--accent-light);
+ border-right-color: var(--accent);
+ }
+
+ body.resizing-outline {
+ cursor: col-resize;
+ user-select: none;
+ }
+
#sidebar-header {
padding: 16px;
border-bottom: 1px solid var(--border);
@@ -1163,7 +1188,8 @@
);
}
- html[data-outline="hidden"] #outline {
+ html[data-outline="hidden"] #outline,
+ html[data-outline="hidden"] #outline-resizer {
display: none !important;
}
@@ -1804,6 +1830,8 @@
#sidebar,
#outline,
+ #sidebar-resizer,
+ #outline-resizer,
#empty-state,
.copy-btn,
.mermaid-fullscreen-btn,
@@ -1869,6 +1897,8 @@
/* PDF export mode — applied via JS before createPDF capture */
body.print-mode #sidebar,
body.print-mode #outline,
+ body.print-mode #sidebar-resizer,
+ body.print-mode #outline-resizer,
body.print-mode #empty-state,
body.print-mode .copy-btn,
body.print-mode .mermaid-fullscreen-btn {
@@ -2109,6 +2139,8 @@ Markdown Viewer
+
On this page
diff --git a/src/main.ts b/src/main.ts
index e72adc6..06eb29f 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -27,15 +27,19 @@ import {
BODY_FONT_KEY,
CODE_FONT_KEY,
CUSTOM_FONT_SIZE_KEY,
+ DEFAULT_OUTLINE_WIDTH,
DEFAULT_SIDEBAR_WIDTH,
FONTSIZE_KEY,
FONT_WEIGHT_KEY,
MAX_CUSTOM_FONT_SIZE,
+ MAX_OUTLINE_WIDTH,
MAX_SIDEBAR_WIDTH,
MIN_CUSTOM_FONT_SIZE,
+ MIN_OUTLINE_WIDTH,
MIN_SIDEBAR_WIDTH,
FINDER_OPEN_KEY,
OUTLINE_PREF_KEY,
+ OUTLINE_WIDTH_KEY,
SIDEBAR_WIDTH_KEY,
applyBodyFontToDOM,
applyCodeFontToDOM,
@@ -43,6 +47,7 @@ import {
applyFontSizeToDOM,
applyFontWeightToDOM,
applyOutlinePrefToDOM,
+ applyOutlineWidthToDOM,
applySidebarWidthToDOM,
resolveInitialCustomFontSize,
resolveInitialFontFamily,
@@ -50,6 +55,7 @@ import {
resolveInitialFinderOpenPref,
resolveInitialFontWeight,
resolveInitialOutlinePref,
+ resolveInitialOutlineWidth,
resolveInitialSidebarWidth,
setSegmentActive,
type FinderOpenPref,
@@ -167,6 +173,9 @@ async function initTheme(): Promise
{
const savedSidebarWidth = resolveInitialSidebarWidth(
await store.get(SIDEBAR_WIDTH_KEY)
);
+ const savedOutlineWidth = resolveInitialOutlineWidth(
+ await store.get(OUTLINE_WIDTH_KEY)
+ );
const storeCustoms = sanitizeCustomThemes(
await store.get(CUSTOM_THEMES_KEY)
@@ -202,6 +211,7 @@ async function initTheme(): Promise {
applyFontSize(savedFontSize);
applyOutlinePref(savedOutline);
applySidebarWidthToDOM(document.documentElement, savedSidebarWidth);
+ applyOutlineWidthToDOM(document.documentElement, savedOutlineWidth);
applyBodyFontToDOM(document.documentElement, currentBodyFont);
applyCodeFontToDOM(document.documentElement, currentCodeFont);
applyFontWeightToDOM(document.documentElement, currentFontWeight);
@@ -442,6 +452,9 @@ const examplesBtn = document.getElementById(
) as HTMLButtonElement;
const outlineEl = document.getElementById("outline") as HTMLDivElement;
const outlineNav = document.getElementById("outline-nav") as HTMLElement;
+const outlineResizer = document.getElementById(
+ "outline-resizer"
+) as HTMLDivElement;
const titlebarFilename = document.getElementById(
"titlebar-filename"
) as HTMLSpanElement;
@@ -738,6 +751,64 @@ function initSidebarResizer(): void {
});
}
+// --- Outline resizing ---
+
+let outlineDragging = false;
+let outlineDragStartX = 0;
+let outlineDragStartWidth = 0;
+
+function currentOutlineWidth(): number {
+ return outlineEl.getBoundingClientRect().width;
+}
+
+function setOutlineWidth(px: number): void {
+ const clamped = Math.min(
+ MAX_OUTLINE_WIDTH,
+ Math.max(MIN_OUTLINE_WIDTH, px)
+ );
+ applyOutlineWidthToDOM(document.documentElement, clamped);
+}
+
+function initOutlineResizer(): void {
+ outlineResizer.addEventListener("pointerdown", (e) => {
+ e.preventDefault();
+ // Capture so moves/up keep firing even if the cursor leaves the window.
+ outlineResizer.setPointerCapture(e.pointerId);
+ outlineDragging = true;
+ outlineDragStartX = e.clientX;
+ outlineDragStartWidth = currentOutlineWidth();
+ document.body.classList.add("resizing-outline");
+ });
+
+ outlineResizer.addEventListener("pointermove", (e) => {
+ if (!outlineDragging) return;
+ // The handle sits on the outline's left edge: dragging right shrinks it.
+ setOutlineWidth(outlineDragStartWidth - (e.clientX - outlineDragStartX));
+ });
+
+ function endOutlineDrag(): void {
+ if (!outlineDragging) return;
+ outlineDragging = false;
+ document.body.classList.remove("resizing-outline");
+ }
+
+ outlineResizer.addEventListener("pointerup", async (e) => {
+ if (!outlineDragging) return;
+ outlineResizer.releasePointerCapture(e.pointerId);
+ endOutlineDrag();
+ await savePref(OUTLINE_WIDTH_KEY, Math.round(currentOutlineWidth()));
+ });
+
+ outlineResizer.addEventListener("pointercancel", () => {
+ endOutlineDrag();
+ });
+
+ outlineResizer.addEventListener("dblclick", async () => {
+ setOutlineWidth(DEFAULT_OUTLINE_WIDTH);
+ await savePref(OUTLINE_WIDTH_KEY, DEFAULT_OUTLINE_WIDTH);
+ });
+}
+
// --- Preferences modal ---
const prefsBackdrop = document.getElementById(
@@ -1592,6 +1663,7 @@ async function init(): Promise {
initSearch();
initPreferences();
initSidebarResizer();
+ initOutlineResizer();
// Runtime opens (hot-start file association, "Open With", CLI events)
appWindow.listen("open-folder", (event) => {
@@ -1995,10 +2067,12 @@ function buildOutline(): void {
if (headings.length === 0) {
outlineEl.style.display = "none";
+ outlineResizer.style.display = "none";
return;
}
outlineEl.style.display = "block";
+ outlineResizer.style.display = "block";
const items: { el: HTMLAnchorElement; heading: Element }[] = [];
for (const h of headings) {
diff --git a/src/preferences.test.ts b/src/preferences.test.ts
index c88cc4f..c7b7d8e 100644
--- a/src/preferences.test.ts
+++ b/src/preferences.test.ts
@@ -24,8 +24,11 @@ import {
applyFontWeightToDOM,
applyCustomFontSizeToDOM,
applySidebarWidthToDOM,
+ applyOutlineWidthToDOM,
resolveInitialSidebarWidth,
isValidSidebarWidth,
+ resolveInitialOutlineWidth,
+ isValidOutlineWidth,
effectiveFontSizePx,
PRESET_FONT_SIZE_PX,
MIN_CUSTOM_FONT_SIZE,
@@ -33,6 +36,9 @@ import {
MIN_SIDEBAR_WIDTH,
MAX_SIDEBAR_WIDTH,
DEFAULT_SIDEBAR_WIDTH,
+ MIN_OUTLINE_WIDTH,
+ MAX_OUTLINE_WIDTH,
+ DEFAULT_OUTLINE_WIDTH,
} from "./preferences";
describe("resolveDark", () => {
@@ -403,3 +409,41 @@ describe("sidebar width", () => {
expect(root.style.getPropertyValue("--sidebar-width")).toBe("420px");
});
});
+
+describe("outline width", () => {
+ it("isValidOutlineWidth accepts values inside the range", () => {
+ expect(isValidOutlineWidth(MIN_OUTLINE_WIDTH)).toBe(true);
+ expect(isValidOutlineWidth(280)).toBe(true);
+ expect(isValidOutlineWidth(MAX_OUTLINE_WIDTH)).toBe(true);
+ });
+
+ it("isValidOutlineWidth rejects out-of-range or non-numbers", () => {
+ expect(isValidOutlineWidth(MIN_OUTLINE_WIDTH - 1)).toBe(false);
+ expect(isValidOutlineWidth(MAX_OUTLINE_WIDTH + 1)).toBe(false);
+ expect(isValidOutlineWidth(NaN)).toBe(false);
+ expect(isValidOutlineWidth(Infinity)).toBe(false);
+ expect(isValidOutlineWidth("280")).toBe(false);
+ expect(isValidOutlineWidth(null)).toBe(false);
+ expect(isValidOutlineWidth(undefined)).toBe(false);
+ });
+
+ it("resolveInitialOutlineWidth keeps a valid value", () => {
+ expect(resolveInitialOutlineWidth(260)).toBe(260);
+ expect(resolveInitialOutlineWidth(MIN_OUTLINE_WIDTH)).toBe(
+ MIN_OUTLINE_WIDTH
+ );
+ });
+
+ it("resolveInitialOutlineWidth falls back to the default for bad values", () => {
+ expect(resolveInitialOutlineWidth(null)).toBe(DEFAULT_OUTLINE_WIDTH);
+ expect(resolveInitialOutlineWidth("260")).toBe(DEFAULT_OUTLINE_WIDTH);
+ expect(resolveInitialOutlineWidth(10)).toBe(DEFAULT_OUTLINE_WIDTH);
+ expect(resolveInitialOutlineWidth(10000)).toBe(DEFAULT_OUTLINE_WIDTH);
+ });
+
+ it("applyOutlineWidthToDOM sets --outline-width in px", () => {
+ const root = document.createElement("html");
+ applyOutlineWidthToDOM(root, 300);
+ expect(root.style.getPropertyValue("--outline-width")).toBe("300px");
+ });
+});
diff --git a/src/preferences.ts b/src/preferences.ts
index ab5eb67..b12b588 100644
--- a/src/preferences.ts
+++ b/src/preferences.ts
@@ -40,6 +40,11 @@ export const MIN_SIDEBAR_WIDTH = 160;
export const MAX_SIDEBAR_WIDTH = 640;
export const DEFAULT_SIDEBAR_WIDTH = 260;
+export const OUTLINE_WIDTH_KEY = "outlineWidth";
+export const MIN_OUTLINE_WIDTH = 160;
+export const MAX_OUTLINE_WIDTH = 480;
+export const DEFAULT_OUTLINE_WIDTH = 220;
+
export const FONT_WEIGHT_VALUES = [
"300",
"400",
@@ -194,6 +199,23 @@ export function applySidebarWidthToDOM(root: HTMLElement, px: number): void {
root.style.setProperty("--sidebar-width", `${px}px`);
}
+export function isValidOutlineWidth(value: unknown): value is number {
+ return (
+ typeof value === "number" &&
+ Number.isFinite(value) &&
+ value >= MIN_OUTLINE_WIDTH &&
+ value <= MAX_OUTLINE_WIDTH
+ );
+}
+
+export function resolveInitialOutlineWidth(value: unknown): number {
+ return isValidOutlineWidth(value) ? value : DEFAULT_OUTLINE_WIDTH;
+}
+
+export function applyOutlineWidthToDOM(root: HTMLElement, px: number): void {
+ root.style.setProperty("--outline-width", `${px}px`);
+}
+
export function setSegmentActive(group: HTMLElement, value: string): void {
for (const btn of group.querySelectorAll("button")) {
btn.classList.toggle("active", btn.dataset.value === value);