diff --git a/widgets/horizontal/index.html b/widgets/horizontal/index.html
index 318dfcf..c0416ef 100644
--- a/widgets/horizontal/index.html
+++ b/widgets/horizontal/index.html
@@ -5,8 +5,8 @@
-
-
+
+
diff --git a/widgets/shared/scripts/emotes.js b/widgets/horizontal/scripts/emotes.js
similarity index 100%
rename from widgets/shared/scripts/emotes.js
rename to widgets/horizontal/scripts/emotes.js
diff --git a/widgets/shared/scripts/tasklist-view.js b/widgets/horizontal/scripts/tasklist-view.js
similarity index 100%
rename from widgets/shared/scripts/tasklist-view.js
rename to widgets/horizontal/scripts/tasklist-view.js
diff --git a/widgets/streamer-only/index.html b/widgets/streamer-only/index.html
index 033836f..c0416ef 100644
--- a/widgets/streamer-only/index.html
+++ b/widgets/streamer-only/index.html
@@ -5,7 +5,7 @@
-
+
diff --git a/widgets/streamer-only/scripts/emotes.js b/widgets/streamer-only/scripts/emotes.js
new file mode 100644
index 0000000..881f87e
--- /dev/null
+++ b/widgets/streamer-only/scripts/emotes.js
@@ -0,0 +1,273 @@
+"use strict";
+
+class EmoteManager {
+ #emotes = new Map();
+ #channelName = "";
+ #channelId = "";
+ #providers = ["7tv", "bttv", "ffz"];
+ #size = "1x";
+ #loaded = false;
+ #cacheTTL = 24 * 60 * 60 * 1000;
+ #CACHE_KEY = "emote-cache-v1";
+ #pattern = null;
+ #patternSize = 0;
+
+ constructor(config = {}) {
+ this.#channelName = config.channelName || "";
+ this.#channelId = config.channelId || "";
+ if (config.providers) this.#providers = config.providers;
+ if (config.size) this.#size = config.size;
+ }
+
+ get loaded() {
+ return this.#loaded;
+ }
+
+ get emoteCount() {
+ return this.#emotes.size;
+ }
+
+ get providers() {
+ return [...this.#providers];
+ }
+
+ getEmote(name) {
+ return this.#emotes.get(name) || null;
+ }
+
+ #buildPattern() {
+ if (this.#emotes.size === 0) {
+ this.#pattern = null;
+ this.#patternSize = 0;
+ return;
+ }
+
+ if (this.#pattern && this.#emotes.size === this.#patternSize) return;
+
+ const sorted = [...this.#emotes.keys()].sort(
+ (a, b) => b.length - a.length,
+ );
+ const escaped = sorted.map((n) =>
+ n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
+ );
+ this.#pattern = new RegExp(
+ `(? {
+ const emote = this.#emotes.get(match);
+ if (!emote) return match;
+ console.log(emote);
+ return `
`;
+ });
+ }
+
+ async init() {
+ const cached = this.#loadCache();
+ if (cached) {
+ this.#emotes = new Map(Object.entries(cached.emotes));
+ this.#loaded = true;
+ this.#patternSize = 0;
+ return;
+ }
+
+ await this.#fetchAll();
+ this.#loaded = true;
+ this.#patternSize = 0;
+ this.#saveCache();
+ }
+
+ async forceRefresh() {
+ this.#emotes.clear();
+ this.#patternSize = 0;
+ await this.#fetchAll();
+ this.#loaded = true;
+ this.#patternSize = 0;
+ this.#saveCache();
+ }
+
+ async #fetchAll() {
+ const fetchers = [];
+ if (this.#providers.includes("7tv")) fetchers.push(this.#fetch7TV());
+ if (this.#providers.includes("bttv")) fetchers.push(this.#fetchBTTV());
+ if (this.#providers.includes("ffz")) fetchers.push(this.#fetchFFZ());
+ await Promise.allSettled(fetchers);
+ }
+
+ async #fetch7TV() {
+ try {
+ const globalResp = await fetch(
+ "https://7tv.io/v3/emote-sets/global",
+ );
+ if (!globalResp.ok) return;
+ const globalData = await globalResp.json();
+ if (globalData.emotes) {
+ for (const emote of globalData.emotes) {
+ this.#add7TVEmote(emote);
+ }
+ }
+
+ if (this.#channelId) {
+ const userResp = await fetch(
+ `https://7tv.io/v3/users/twitch/${this.#channelId}`,
+ );
+ if (!userResp.ok) return;
+ const userData = await userResp.json();
+ if (userData.emote_set?.emotes) {
+ for (const emote of userData.emote_set.emotes) {
+ this.#add7TVEmote(emote);
+ }
+ }
+ }
+ } catch (e) {
+ console.warn("7TV emote fetch failed:", e.message);
+ }
+ }
+
+ #add7TVEmote(emote) {
+ const entry = emote.data || emote;
+ const id = entry.id || emote.id;
+ const name = entry.name || emote.name;
+ if (!id || !name) return;
+ const nameLower = name;
+ const host = entry.host.url;
+ this.#emotes.set(nameLower, {
+ url: `https:${host}/${this.#size}.webp`,
+ provider: "7tv",
+ animated: entry.animated || false,
+ });
+ }
+
+ async #fetchBTTV() {
+ try {
+ const globalResp = await fetch(
+ "https://api.betterttv.net/3/cached/emotes/global",
+ );
+ if (!globalResp.ok) return;
+ const globalData = await globalResp.json();
+ if (Array.isArray(globalData)) {
+ for (const emote of globalData) {
+ this.#emotes.set(emote.code, {
+ url: `https://cdn.betterttv.net/emote/${emote.id}/${this.#size}`,
+ provider: "bttv",
+ animated: emote.imageType === "gif",
+ });
+ }
+ }
+
+ if (this.#channelId) {
+ const userResp = await fetch(
+ `https://api.betterttv.net/3/cached/users/twitch/${this.#channelId}`,
+ );
+ if (!userResp.ok) return;
+ const userData = await userResp.json();
+ const allEmotes = [
+ ...(userData.channelEmotes || []),
+ ...(userData.sharedEmotes || []),
+ ];
+ for (const emote of allEmotes) {
+ this.#emotes.set(emote.code, {
+ url: `https://cdn.betterttv.net/emote/${emote.id}/${this.#size}`,
+ provider: "bttv",
+ animated: emote.imageType === "gif",
+ });
+ }
+ }
+ } catch (e) {
+ console.warn("BTTV emote fetch failed:", e.message);
+ }
+ }
+
+ async #fetchFFZ() {
+ try {
+ const globalResp = await fetch(
+ "https://api.frankerfacez.com/v1/set/global",
+ );
+ if (!globalResp.ok) return;
+ const globalData = await globalResp.json();
+ if (globalData.sets) {
+ for (const setId in globalData.sets) {
+ const set = globalData.sets[setId];
+ for (const emote of set.emoticons || []) {
+ if (emote.modifier) continue;
+ this.#emotes.set(emote.name, {
+ url: emote.urls[this.#toFFZSize(this.#size)],
+ provider: "ffz",
+ animated: false,
+ });
+ }
+ }
+ }
+
+ const ffzId = this.#channelName || this.#channelId;
+ if (ffzId) {
+ const roomUrl = this.#channelId
+ ? `https://api.frankerfacez.com/v1/room/id/${this.#channelId}`
+ : `https://api.frankerfacez.com/v1/room/${this.#channelName}`;
+ const roomResp = await fetch(roomUrl);
+ if (!roomResp.ok) return;
+ const roomData = await roomResp.json();
+ if (roomData.sets) {
+ for (const setId in roomData.sets) {
+ const set = roomData.sets[setId];
+ for (const emote of set.emoticons || []) {
+ if (emote.modifier) continue;
+ this.#emotes.set(emote.name, {
+ url: emote.urls[this.#toFFZSize(this.#size)],
+ provider: "ffz",
+ animated: false,
+ });
+ }
+ }
+ }
+ }
+ } catch (e) {
+ console.warn("FFZ emote fetch failed:", e.message);
+ }
+ }
+
+ #toFFZSize(size) {
+ switch (size) {
+ case "3x":
+ case "4x":
+ return "4";
+ case "2x":
+ return "2";
+ default:
+ return "1";
+ }
+ }
+
+ #saveCache() {
+ try {
+ const cache = {
+ timestamp: Date.now(),
+ channelId: this.#channelId,
+ emotes: Object.fromEntries(this.#emotes),
+ };
+ localStorage.setItem(this.#CACHE_KEY, JSON.stringify(cache));
+ } catch (e) {}
+ }
+
+ #loadCache() {
+ try {
+ const raw = localStorage.getItem(this.#CACHE_KEY);
+ if (!raw) return null;
+ const cache = JSON.parse(raw);
+ if (cache.channelId !== this.#channelId) return null;
+ if (Date.now() - cache.timestamp > this.#cacheTTL) return null;
+ return cache;
+ } catch (e) {
+ return null;
+ }
+ }
+}
diff --git a/widgets/vertical/index.html b/widgets/vertical/index.html
index 318dfcf..c0416ef 100644
--- a/widgets/vertical/index.html
+++ b/widgets/vertical/index.html
@@ -5,8 +5,8 @@
-
-
+
+
diff --git a/widgets/vertical/scripts/emotes.js b/widgets/vertical/scripts/emotes.js
new file mode 100644
index 0000000..881f87e
--- /dev/null
+++ b/widgets/vertical/scripts/emotes.js
@@ -0,0 +1,273 @@
+"use strict";
+
+class EmoteManager {
+ #emotes = new Map();
+ #channelName = "";
+ #channelId = "";
+ #providers = ["7tv", "bttv", "ffz"];
+ #size = "1x";
+ #loaded = false;
+ #cacheTTL = 24 * 60 * 60 * 1000;
+ #CACHE_KEY = "emote-cache-v1";
+ #pattern = null;
+ #patternSize = 0;
+
+ constructor(config = {}) {
+ this.#channelName = config.channelName || "";
+ this.#channelId = config.channelId || "";
+ if (config.providers) this.#providers = config.providers;
+ if (config.size) this.#size = config.size;
+ }
+
+ get loaded() {
+ return this.#loaded;
+ }
+
+ get emoteCount() {
+ return this.#emotes.size;
+ }
+
+ get providers() {
+ return [...this.#providers];
+ }
+
+ getEmote(name) {
+ return this.#emotes.get(name) || null;
+ }
+
+ #buildPattern() {
+ if (this.#emotes.size === 0) {
+ this.#pattern = null;
+ this.#patternSize = 0;
+ return;
+ }
+
+ if (this.#pattern && this.#emotes.size === this.#patternSize) return;
+
+ const sorted = [...this.#emotes.keys()].sort(
+ (a, b) => b.length - a.length,
+ );
+ const escaped = sorted.map((n) =>
+ n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
+ );
+ this.#pattern = new RegExp(
+ `(? {
+ const emote = this.#emotes.get(match);
+ if (!emote) return match;
+ console.log(emote);
+ return `
`;
+ });
+ }
+
+ async init() {
+ const cached = this.#loadCache();
+ if (cached) {
+ this.#emotes = new Map(Object.entries(cached.emotes));
+ this.#loaded = true;
+ this.#patternSize = 0;
+ return;
+ }
+
+ await this.#fetchAll();
+ this.#loaded = true;
+ this.#patternSize = 0;
+ this.#saveCache();
+ }
+
+ async forceRefresh() {
+ this.#emotes.clear();
+ this.#patternSize = 0;
+ await this.#fetchAll();
+ this.#loaded = true;
+ this.#patternSize = 0;
+ this.#saveCache();
+ }
+
+ async #fetchAll() {
+ const fetchers = [];
+ if (this.#providers.includes("7tv")) fetchers.push(this.#fetch7TV());
+ if (this.#providers.includes("bttv")) fetchers.push(this.#fetchBTTV());
+ if (this.#providers.includes("ffz")) fetchers.push(this.#fetchFFZ());
+ await Promise.allSettled(fetchers);
+ }
+
+ async #fetch7TV() {
+ try {
+ const globalResp = await fetch(
+ "https://7tv.io/v3/emote-sets/global",
+ );
+ if (!globalResp.ok) return;
+ const globalData = await globalResp.json();
+ if (globalData.emotes) {
+ for (const emote of globalData.emotes) {
+ this.#add7TVEmote(emote);
+ }
+ }
+
+ if (this.#channelId) {
+ const userResp = await fetch(
+ `https://7tv.io/v3/users/twitch/${this.#channelId}`,
+ );
+ if (!userResp.ok) return;
+ const userData = await userResp.json();
+ if (userData.emote_set?.emotes) {
+ for (const emote of userData.emote_set.emotes) {
+ this.#add7TVEmote(emote);
+ }
+ }
+ }
+ } catch (e) {
+ console.warn("7TV emote fetch failed:", e.message);
+ }
+ }
+
+ #add7TVEmote(emote) {
+ const entry = emote.data || emote;
+ const id = entry.id || emote.id;
+ const name = entry.name || emote.name;
+ if (!id || !name) return;
+ const nameLower = name;
+ const host = entry.host.url;
+ this.#emotes.set(nameLower, {
+ url: `https:${host}/${this.#size}.webp`,
+ provider: "7tv",
+ animated: entry.animated || false,
+ });
+ }
+
+ async #fetchBTTV() {
+ try {
+ const globalResp = await fetch(
+ "https://api.betterttv.net/3/cached/emotes/global",
+ );
+ if (!globalResp.ok) return;
+ const globalData = await globalResp.json();
+ if (Array.isArray(globalData)) {
+ for (const emote of globalData) {
+ this.#emotes.set(emote.code, {
+ url: `https://cdn.betterttv.net/emote/${emote.id}/${this.#size}`,
+ provider: "bttv",
+ animated: emote.imageType === "gif",
+ });
+ }
+ }
+
+ if (this.#channelId) {
+ const userResp = await fetch(
+ `https://api.betterttv.net/3/cached/users/twitch/${this.#channelId}`,
+ );
+ if (!userResp.ok) return;
+ const userData = await userResp.json();
+ const allEmotes = [
+ ...(userData.channelEmotes || []),
+ ...(userData.sharedEmotes || []),
+ ];
+ for (const emote of allEmotes) {
+ this.#emotes.set(emote.code, {
+ url: `https://cdn.betterttv.net/emote/${emote.id}/${this.#size}`,
+ provider: "bttv",
+ animated: emote.imageType === "gif",
+ });
+ }
+ }
+ } catch (e) {
+ console.warn("BTTV emote fetch failed:", e.message);
+ }
+ }
+
+ async #fetchFFZ() {
+ try {
+ const globalResp = await fetch(
+ "https://api.frankerfacez.com/v1/set/global",
+ );
+ if (!globalResp.ok) return;
+ const globalData = await globalResp.json();
+ if (globalData.sets) {
+ for (const setId in globalData.sets) {
+ const set = globalData.sets[setId];
+ for (const emote of set.emoticons || []) {
+ if (emote.modifier) continue;
+ this.#emotes.set(emote.name, {
+ url: emote.urls[this.#toFFZSize(this.#size)],
+ provider: "ffz",
+ animated: false,
+ });
+ }
+ }
+ }
+
+ const ffzId = this.#channelName || this.#channelId;
+ if (ffzId) {
+ const roomUrl = this.#channelId
+ ? `https://api.frankerfacez.com/v1/room/id/${this.#channelId}`
+ : `https://api.frankerfacez.com/v1/room/${this.#channelName}`;
+ const roomResp = await fetch(roomUrl);
+ if (!roomResp.ok) return;
+ const roomData = await roomResp.json();
+ if (roomData.sets) {
+ for (const setId in roomData.sets) {
+ const set = roomData.sets[setId];
+ for (const emote of set.emoticons || []) {
+ if (emote.modifier) continue;
+ this.#emotes.set(emote.name, {
+ url: emote.urls[this.#toFFZSize(this.#size)],
+ provider: "ffz",
+ animated: false,
+ });
+ }
+ }
+ }
+ }
+ } catch (e) {
+ console.warn("FFZ emote fetch failed:", e.message);
+ }
+ }
+
+ #toFFZSize(size) {
+ switch (size) {
+ case "3x":
+ case "4x":
+ return "4";
+ case "2x":
+ return "2";
+ default:
+ return "1";
+ }
+ }
+
+ #saveCache() {
+ try {
+ const cache = {
+ timestamp: Date.now(),
+ channelId: this.#channelId,
+ emotes: Object.fromEntries(this.#emotes),
+ };
+ localStorage.setItem(this.#CACHE_KEY, JSON.stringify(cache));
+ } catch (e) {}
+ }
+
+ #loadCache() {
+ try {
+ const raw = localStorage.getItem(this.#CACHE_KEY);
+ if (!raw) return null;
+ const cache = JSON.parse(raw);
+ if (cache.channelId !== this.#channelId) return null;
+ if (Date.now() - cache.timestamp > this.#cacheTTL) return null;
+ return cache;
+ } catch (e) {
+ return null;
+ }
+ }
+}
diff --git a/widgets/vertical/scripts/tasklist-view.js b/widgets/vertical/scripts/tasklist-view.js
new file mode 100644
index 0000000..70ab5d5
--- /dev/null
+++ b/widgets/vertical/scripts/tasklist-view.js
@@ -0,0 +1,591 @@
+// Task list view
+
+class TaskList {
+ #data = [];
+ #firstRender = true;
+ #pendingRemovals = 0;
+
+ #scroll = {
+ offset: 0,
+ contentH: 0,
+ viewportH: 0,
+ speed: 50,
+ raf: null,
+ lastT: 0,
+ active: false,
+ wantStop: false,
+ };
+
+ #els = {};
+
+ #ANIM_DURATION = 400;
+ #ANIM_EASING = "cubic-bezier(0.22, 1, 0.36, 1)";
+
+ #parseHexColor(hex) {
+ if (typeof hex !== "string") return null;
+ const s = hex.trim();
+ const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(s);
+ if (!m) return null;
+ let h = m[1].toLowerCase();
+ if (h.length === 3) {
+ h = h
+ .split("")
+ .map((c) => c + c)
+ .join("");
+ }
+ const r = parseInt(h.slice(0, 2), 16);
+ const g = parseInt(h.slice(2, 4), 16);
+ const b = parseInt(h.slice(4, 6), 16);
+ return { r, g, b };
+ }
+
+ #relativeLuminance({ r, g, b }) {
+ const toLinear = (v) => {
+ const s = v / 255;
+ return s <= 0.04045
+ ? s / 12.92
+ : Math.pow((s + 0.055) / 1.055, 2.4);
+ };
+ const R = toLinear(r);
+ const G = toLinear(g);
+ const B = toLinear(b);
+ return 0.2126 * R + 0.7152 * G + 0.0722 * B;
+ }
+
+ constructor(container) {
+ const el =
+ typeof container === "string"
+ ? document.querySelector(container)
+ : container;
+
+ this.#els = {
+ track: el.querySelector(".scroll-track"),
+ content: el.querySelector(".scroll-content"),
+ viewport: el.querySelector(".scroll-viewport"),
+ };
+ }
+
+ // ── public API ─────────────────────────────────────
+
+ load(sections) {
+ this.#data = structuredClone(sections);
+ this.#render();
+ return this;
+ }
+
+ addSection(section) {
+ this.#data.push(structuredClone(section));
+ this.#render();
+ return this;
+ }
+
+ removeSection(id) {
+ const i = this.#data.findIndex((s) => s.id === id);
+ if (i === -1) return this;
+ this.#data.splice(i, 1);
+ this.#render();
+ return this;
+ }
+
+ updateSection(id, updater) {
+ const section = this.#data.find((s) => s.id === id);
+ if (!section) return this;
+ updater(section);
+ this.#render();
+ return this;
+ }
+
+ addTask(sectionId, task, sectionTitle) {
+ if (!this.#data.find((s) => s.id === sectionId)) {
+ this.addSection({
+ id: sectionId,
+ title: sectionTitle || sectionId,
+ tasks: [],
+ });
+ }
+
+ return this.updateSection(sectionId, (s) => {
+ s.tasks.push(structuredClone(task));
+ });
+ }
+
+ removeTask(sectionId, taskIndex) {
+ this.updateSection(sectionId, (s) => {
+ s.tasks.splice(taskIndex, 1);
+ });
+ this.#data = this.#data.filter((s) => s.tasks.length > 0);
+ this.#render();
+ return this;
+ }
+
+ editTask(sectionId, taskIndex, newText, userColor) {
+ return this.updateSection(sectionId, (s) => {
+ if (s.tasks[taskIndex]) {
+ s.tasks[taskIndex].text = newText;
+ }
+ });
+ }
+
+ doneTask(sectionId, taskIndex) {
+ return this.updateSection(sectionId, (s) => {
+ if (s.tasks[taskIndex]) {
+ s.tasks[taskIndex].done = true;
+ s.tasks[taskIndex].focused = false;
+ }
+ });
+ }
+
+ undoneTask(sectionId, taskIndex) {
+ return this.updateSection(sectionId, (s) => {
+ if (s.tasks[taskIndex]) {
+ s.tasks[taskIndex].done = false;
+ }
+ });
+ }
+
+ focusTask(sectionId, taskIndex) {
+ // unfocus any previously focused task
+ this.unfocusTask(sectionId);
+
+ return this.updateSection(sectionId, (s) => {
+ if (s.tasks[taskIndex]) {
+ s.tasks[taskIndex].focused = true;
+ }
+ });
+ }
+
+ unfocusTask(sectionId) {
+ return this.updateSection(sectionId, (s) => {
+ for (let i = 0; i < s.tasks.length; i++) {
+ s.tasks[i].focused = false;
+ }
+ });
+ }
+
+ clearmydone(sectionId) {
+ this.updateSection(sectionId, (s) => {
+ s.tasks = s.tasks.filter((t) => !t.done);
+ });
+ this.#data = this.#data.filter((s) => s.tasks.length > 0);
+ this.#render();
+ return this;
+ }
+
+ cleardone() {
+ this.#data.forEach((s) => {
+ s.tasks = s.tasks.filter((t) => !t.done);
+ });
+ this.#data = this.#data.filter((s) => s.tasks.length > 0);
+ this.#render();
+ return this;
+ }
+
+ getData() {
+ return structuredClone(this.#data);
+ }
+
+ get sectionCount() {
+ return this.#data.length;
+ }
+
+ get taskCount() {
+ return this.#data.reduce((sum, s) => sum + s.tasks.length, 0);
+ }
+
+ get doneCount() {
+ return this.#data.reduce(
+ (sum, s) => sum + s.tasks.filter((t) => t.done).length,
+ 0,
+ );
+ }
+
+ destroy() {
+ this.#forceStop();
+ this.#els.content.innerHTML = "";
+ this.#data = [];
+ this.#firstRender = true;
+ }
+
+ // ── render ─────────────────────────────────────────
+
+ #render() {
+ if (this.#firstRender) {
+ this.#data.forEach((s) =>
+ this.#els.content.appendChild(this.#createSectionEl(s)),
+ );
+ this.#firstRender = false;
+ } else {
+ this.#patchContainer(this.#els.content, this.#data, true);
+ }
+
+ if (this.#pendingRemovals === 0) {
+ this.#syncScroll();
+ }
+ }
+
+ // ── animations ─────────────────────────────────────
+
+ #animateIn(el) {
+ el.style.overflow = "hidden";
+
+ const h = el.scrollHeight;
+ el.style.height = "0px";
+ el.style.opacity = "0";
+
+ requestAnimationFrame(() => {
+ const anim = el.animate(
+ [
+ {
+ height: "0px",
+ opacity: 0,
+ transform: "translateX(-8px)",
+ },
+ {
+ height: h + "px",
+ opacity: 1,
+ transform: "translateX(0)",
+ },
+ ],
+ {
+ duration: this.#ANIM_DURATION,
+ easing: this.#ANIM_EASING,
+ fill: "forwards",
+ },
+ );
+
+ anim.onfinish = () => {
+ el.style.height = "";
+ el.style.opacity = "";
+ el.style.overflow = "";
+ el.style.transform = "";
+ anim.cancel();
+ this.#syncScroll();
+ };
+ });
+ }
+
+ #animateRemove(el) {
+ this.#pendingRemovals++;
+ const h = el.scrollHeight;
+
+ const anim = el.animate(
+ [
+ { height: h + "px", opacity: 1, transform: "translateX(0)" },
+ { height: "0px", opacity: 0, transform: "translateX(-8px)" },
+ ],
+ {
+ duration: this.#ANIM_DURATION,
+ easing: this.#ANIM_EASING,
+ fill: "forwards",
+ },
+ );
+
+ anim.onfinish = () => {
+ el.remove();
+ this.#pendingRemovals--;
+
+ if (this.#pendingRemovals === 0) {
+ this.#syncScroll();
+ }
+ };
+ }
+
+ #animateStrikethrough(el) {
+ el.animate(
+ [
+ { opacity: 0.5, transform: "scale(0.98)" },
+ { opacity: 1, transform: "scale(1)" },
+ ],
+ { duration: 250, easing: "ease-out" },
+ );
+ }
+
+ // ── dom helpers ────────────────────────────────────
+
+ #parseTaskText(text) {
+ if (window.emoteManager && window.emoteManager.loaded) {
+ return window.emoteManager.parseText(text);
+ }
+ return text;
+ }
+
+ #createTaskEl(task, index) {
+ const div = document.createElement("div");
+ div.className =
+ "task" +
+ (task.done ? " done" : "") +
+ (task.focused ? " focused" : "");
+ div.dataset.text = task.text;
+ const numberSpan = document.createElement("span");
+ numberSpan.className = "task-number";
+ numberSpan.textContent = `${index + 1}.`;
+
+ const textSpan = document.createElement("span");
+ textSpan.className = "task-text";
+ textSpan.innerHTML = this.#parseTaskText(task.text);
+
+ div.replaceChildren(numberSpan, textSpan);
+ return div;
+ }
+
+ #createSectionEl(section) {
+ const div = document.createElement("div");
+ div.className = "section";
+ div.dataset.key = section.id;
+ if (section.title) {
+ const t = document.createElement("div");
+ t.className = "section-title";
+ t.textContent = section.title;
+ let color =
+ section.color ?? localStorage.getItem(`${section.id}-color`);
+ if (
+ configs.userColorSettings.autoUserColor &&
+ color != undefined &&
+ color != "undefined" &&
+ color != null
+ ) {
+ t.style.setProperty("--user-color", color);
+ t.classList.add("has-user-color");
+ }
+
+ div.appendChild(t);
+ }
+ section.tasks.forEach((task, i) =>
+ div.appendChild(this.#createTaskEl(task, i)),
+ );
+ return div;
+ }
+
+ // ── patching ───────────────────────────────────────
+
+ #patchTasks(sectionEl, tasks, animate) {
+ const existing = [...sectionEl.querySelectorAll(":scope > .task")];
+
+ tasks.forEach((task, i) => {
+ if (i < existing.length) {
+ const el = existing[i];
+ const wantClass =
+ "task" +
+ (task.done ? " done" : "") +
+ (task.focused ? " focused" : "");
+ if (el.className !== wantClass) {
+ el.className = wantClass;
+ if (animate) this.#animateStrikethrough(el);
+ }
+ if (el.dataset.text !== task.text) {
+ el.dataset.text = task.text;
+ const textEl =
+ el.querySelector(".task-text") ||
+ el.querySelector("span:last-child");
+ textEl.innerHTML = this.#parseTaskText(task.text);
+ }
+ el.querySelector(".task-number").textContent = `${i + 1}.`;
+
+ // Update color if changed
+ if (task.color != undefined && task.color != "undefined") {
+ el.style.setProperty("--user-color", task.color);
+ el.classList.add("has-user-color");
+ } else {
+ el.style.removeProperty("--user-color");
+ el.classList.remove("has-user-color");
+ }
+ } else {
+ const newEl = this.#createTaskEl(task, i);
+ sectionEl.appendChild(newEl);
+ if (animate) this.#animateIn(newEl);
+ }
+ });
+
+ for (let i = existing.length - 1; i >= tasks.length; i--) {
+ if (animate) {
+ this.#animateRemove(existing[i]);
+ } else {
+ existing[i].remove();
+ }
+ }
+ }
+
+ #patchContainer(container, sections, animate) {
+ const oldMap = new Map();
+ container.querySelectorAll(":scope > .section").forEach((el) => {
+ oldMap.set(el.dataset.key, el);
+ });
+
+ let cursor = container.firstElementChild;
+
+ sections.forEach((section) => {
+ const key = section.id;
+ const existing = oldMap.get(key);
+
+ if (existing) {
+ this.#patchTasks(existing, section.tasks, animate);
+ if (existing !== cursor) {
+ container.insertBefore(existing, cursor);
+ } else {
+ cursor = cursor.nextElementSibling;
+ }
+ oldMap.delete(key);
+ } else {
+ const newEl = this.#createSectionEl(section);
+ container.insertBefore(newEl, cursor);
+ if (animate) this.#animateIn(newEl);
+ }
+ });
+
+ oldMap.forEach((el) => {
+ if (animate) {
+ this.#animateRemove(el);
+ } else {
+ el.remove();
+ }
+ });
+ }
+
+ // ── scroll ─────────────────────────────────────────
+
+ #tick = (now) => {
+ const s = this.#scroll;
+ const dt = (now - s.lastT) / 1000;
+ s.lastT = now;
+
+ if (dt < 0.2) {
+ s.contentH = this.#els.content.scrollHeight;
+ s.offset += s.speed * dt;
+
+ if (s.contentH > 0 && s.offset >= s.contentH) {
+ s.offset -= s.contentH;
+
+ if (s.wantStop) {
+ s.wantStop = false;
+ this.#forceStop();
+ return;
+ }
+ }
+
+ this.#els.track.style.transform = `translateY(${-s.offset}px)`;
+ }
+
+ s.raf = requestAnimationFrame(this.#tick);
+ };
+
+ #startScroll() {
+ const s = this.#scroll;
+ s.wantStop = false;
+ if (s.active) return;
+ s.active = true;
+ s.lastT = performance.now();
+ s.raf = requestAnimationFrame(this.#tick);
+ }
+
+ #requestStop() {
+ if (!this.#scroll.active) return;
+ this.#scroll.wantStop = true;
+ }
+
+ #forceStop() {
+ const s = this.#scroll;
+ s.active = false;
+ s.wantStop = false;
+ cancelAnimationFrame(s.raf);
+ s.raf = null;
+ s.offset = 0;
+ this.#els.track.style.transform = "";
+
+ const clone = this.#els.track.querySelector(".scroll-clone");
+ if (clone) clone.remove();
+ }
+
+ #syncScroll() {
+ const s = this.#scroll;
+ s.contentH = this.#els.content.scrollHeight;
+ s.viewportH = this.#els.viewport.clientHeight;
+ const needs = s.contentH > s.viewportH;
+
+ let clone = this.#els.track.querySelector(".scroll-clone");
+
+ if (needs) {
+ if (!clone) {
+ clone = this.#els.content.cloneNode(true);
+ clone.id = "";
+ clone.classList.add("scroll-clone");
+ this.#els.track.appendChild(clone);
+ } else {
+ this.#patchContainer(clone, this.#data, false);
+ }
+ if (s.offset >= s.contentH) {
+ s.offset %= s.contentH;
+ }
+ this.#startScroll();
+ } else {
+ if (s.active) {
+ if (clone) this.#patchContainer(clone, this.#data, false);
+ this.#requestStop();
+ } else {
+ if (clone) clone.remove();
+ }
+ }
+ }
+}
+
+// ── usage ──────────────────────────────────────────────
+
+// const list2 = new TaskList(".task-panel");
+
+// list2.load([
+// {
+// title: "someone",
+// tasks: [
+// { text: "dinner" },
+// { text: "watch bad bunny halftime show" },
+// { text: "tie tf down", done: true },
+// { text: "boy dinner", done: true },
+// { text: "3 hour SOOP" },
+// ],
+// },
+// {
+// title: "Cape_Codder",
+// tasks: [
+// { text: "Part 2", done: true },
+// { text: "Ch 1-2", done: true },
+// ],
+// },
+// {
+// title: "sunflawer",
+// tasks: [
+// { text: "fix walls from home office" },
+// { text: "sand walls zzzsdkjhfsk", done: true },
+// { text: "meal prep" },
+// { text: "buy wrist brace maybe" },
+// ],
+// },
+// {
+// title: "extra_user1",
+// tasks: [
+// { text: "go grocery shopping" },
+// { text: "clean the kitchen", done: true },
+// { text: "respond to emails" },
+// ],
+// },
+// ]);
+
+// demo
+
+// setTimeout(() => {
+// list2.addTask("someone2", { text: "new task just dropped" });
+// }, 1000);
+
+// setTimeout(() => {
+// list2.;
+// }, 3000);
+
+// setTimeout(() => {
+// list.toggleTask("sunflawer", 0);
+// }, 10000);
+
+// setTimeout(() => {
+// list.removeSection("Cape_Codder");
+// }, 12000);
+
+// setTimeout(() => {
+// list.removeTask("extra_user1", 1);
+// }, 14000);