From a7147a7f6764a83586eaa9171842fa8c7ffc7c6f Mon Sep 17 00:00:00 2001
From: neumattock <152253273+newmattock@users.noreply.github.com>
Date: Tue, 26 May 2026 01:48:55 -0700
Subject: [PATCH] Add persisted dark theme support
---
eslint.config.mjs | 13 +++++
index.html | 35 +++++++++++++-
package.json | 6 ++-
src/app.js | 4 ++
src/theme.js | 81 +++++++++++++++++++++++++++++++
styles.css | 60 +++++++++++++++++++++--
test/theme.test.js | 118 +++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 309 insertions(+), 8 deletions(-)
create mode 100644 eslint.config.mjs
create mode 100644 src/theme.js
create mode 100644 test/theme.test.js
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 0000000..a2d23ea
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,13 @@
+export default [
+ {
+ files: ["src/**/*.js", "test/**/*.js"],
+ languageOptions: {
+ ecmaVersion: "latest",
+ sourceType: "module",
+ },
+ rules: {
+ "no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
+ semi: ["error", "always"],
+ },
+ },
+];
diff --git a/index.html b/index.html
index 7309515..a40a925 100644
--- a/index.html
+++ b/index.html
@@ -4,12 +4,43 @@
TaskForge — open-source task tracker
+
diff --git a/package.json b/package.json
index f5a7cfe..7d7ab0b 100644
--- a/package.json
+++ b/package.json
@@ -3,9 +3,11 @@
"version": "0.1.0",
"description": "A simple, community-maintained open-source task tracker.",
"private": true,
+ "type": "module",
"scripts": {
- "lint": "eslint src/",
- "format": "prettier --write 'src/**/*.js' 'index.html' 'styles.css'",
+ "lint": "eslint src test",
+ "test": "node --test",
+ "format": "prettier --write 'src/**/*.js' 'test/**/*.js' 'index.html' 'styles.css' 'eslint.config.mjs'",
"start": "python3 -m http.server 8000"
},
"devDependencies": {
diff --git a/src/app.js b/src/app.js
index fe0e9c1..dab69ba 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1,13 +1,17 @@
import { load, save } from "./storage.js";
+import { initThemeToggle } from "./theme.js";
import { createTask, toggleTask, removeTask } from "./tasks.js";
const form = document.getElementById("task-form");
const input = document.getElementById("task-input");
const list = document.getElementById("task-list");
const emptyState = document.getElementById("empty-state");
+const themeToggle = document.getElementById("theme-toggle");
let tasks = load();
+initThemeToggle({ button: themeToggle });
+
function render() {
list.innerHTML = "";
if (tasks.length === 0) {
diff --git a/src/theme.js b/src/theme.js
new file mode 100644
index 0000000..457bbf2
--- /dev/null
+++ b/src/theme.js
@@ -0,0 +1,81 @@
+const THEME_KEY = "taskforge.theme";
+const DARK_QUERY = "(prefers-color-scheme: dark)";
+const THEMES = new Set(["light", "dark"]);
+
+export function getStoredTheme(storage = window.localStorage) {
+ try {
+ const value = storage.getItem(THEME_KEY);
+ return THEMES.has(value) ? value : null;
+ } catch {
+ return null;
+ }
+}
+
+export function getSystemTheme(matchMedia = window.matchMedia) {
+ if (typeof matchMedia !== "function") {
+ return "light";
+ }
+ return matchMedia(DARK_QUERY).matches ? "dark" : "light";
+}
+
+export function resolveTheme({
+ storage = window.localStorage,
+ matchMedia = window.matchMedia,
+} = {}) {
+ return getStoredTheme(storage) ?? getSystemTheme(matchMedia);
+}
+
+export function applyTheme(theme, root = document.documentElement) {
+ const nextTheme = THEMES.has(theme) ? theme : "light";
+ root.dataset.theme = nextTheme;
+ root.style.colorScheme = nextTheme;
+ return nextTheme;
+}
+
+export function saveTheme(theme, storage = window.localStorage) {
+ if (!THEMES.has(theme)) return;
+ try {
+ storage.setItem(THEME_KEY, theme);
+ } catch {
+ // Theme persistence is progressive enhancement; keep the UI usable.
+ }
+}
+
+export function getNextTheme(currentTheme) {
+ return currentTheme === "dark" ? "light" : "dark";
+}
+
+export function initThemeToggle({
+ button,
+ storage = window.localStorage,
+ matchMedia = window.matchMedia,
+ root = document.documentElement,
+} = {}) {
+ let theme = applyTheme(resolveTheme({ storage, matchMedia }), root);
+
+ const renderButton = () => {
+ button.textContent = theme === "dark" ? "Light mode" : "Dark mode";
+ button.setAttribute("aria-pressed", String(theme === "dark"));
+ button.setAttribute(
+ "aria-label",
+ theme === "dark" ? "Switch to light mode" : "Switch to dark mode",
+ );
+ };
+
+ renderButton();
+
+ button.addEventListener("click", () => {
+ theme = applyTheme(getNextTheme(theme), root);
+ saveTheme(theme, storage);
+ renderButton();
+ });
+
+ if (!getStoredTheme(storage) && typeof matchMedia === "function") {
+ const media = matchMedia(DARK_QUERY);
+ media.addEventListener?.("change", (event) => {
+ if (getStoredTheme(storage)) return;
+ theme = applyTheme(event.matches ? "dark" : "light", root);
+ renderButton();
+ });
+ }
+}
diff --git a/styles.css b/styles.css
index 2fa1e18..8438282 100644
--- a/styles.css
+++ b/styles.css
@@ -3,12 +3,32 @@
--fg: #1f2328;
--muted: #6e7781;
--border: #d0d7de;
+ --surface: #f6f8fa;
+ --surface-hover: #eef2f6;
--accent: #0969da;
--accent-fg: #ffffff;
--done: #8c959f;
+ --danger: #cf222e;
+ --danger-bg: rgba(207, 34, 46, 0.08);
+ color-scheme: light;
--radius: 6px;
}
+:root[data-theme="dark"] {
+ --bg: #0d1117;
+ --fg: #e6edf3;
+ --muted: #8b949e;
+ --border: #30363d;
+ --surface: #161b22;
+ --surface-hover: #21262d;
+ --accent: #2f81f7;
+ --accent-fg: #ffffff;
+ --done: #6e7681;
+ --danger: #ff7b72;
+ --danger-bg: rgba(248, 81, 73, 0.14);
+ color-scheme: dark;
+}
+
* {
box-sizing: border-box;
}
@@ -23,15 +43,18 @@ body {
}
.app-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
padding: 2rem 1.25rem 1rem;
- text-align: center;
border-bottom: 1px solid var(--border);
}
.app-header h1 {
margin: 0;
font-size: 1.75rem;
- letter-spacing: -0.01em;
+ letter-spacing: 0;
}
.tagline {
@@ -40,6 +63,27 @@ body {
font-size: 0.95rem;
}
+.theme-toggle {
+ flex: 0 0 auto;
+ min-width: 6.5rem;
+ padding: 0.45rem 0.75rem;
+ font: inherit;
+ color: var(--fg);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ cursor: pointer;
+}
+
+.theme-toggle:hover {
+ background: var(--surface-hover);
+}
+
+.theme-toggle:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
.app-main {
max-width: 36rem;
margin: 0 auto;
@@ -96,6 +140,7 @@ body {
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.75rem;
+ background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
@@ -122,8 +167,8 @@ body {
}
.task-delete:hover {
- color: #cf222e;
- background: rgba(207, 34, 46, 0.08);
+ color: var(--danger);
+ background: var(--danger-bg);
}
.empty-state {
@@ -142,3 +187,10 @@ body {
.app-footer a {
color: inherit;
}
+
+@media (max-width: 36rem) {
+ .app-header {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+}
diff --git a/test/theme.test.js b/test/theme.test.js
new file mode 100644
index 0000000..2852fa3
--- /dev/null
+++ b/test/theme.test.js
@@ -0,0 +1,118 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import {
+ applyTheme,
+ getNextTheme,
+ getStoredTheme,
+ initThemeToggle,
+ resolveTheme,
+ saveTheme,
+} from "../src/theme.js";
+
+function storage(initial = {}) {
+ const values = new Map(Object.entries(initial));
+ return {
+ getItem(key) {
+ return values.has(key) ? values.get(key) : null;
+ },
+ setItem(key, value) {
+ values.set(key, value);
+ },
+ };
+}
+
+function matchMedia(matches) {
+ return () => ({
+ matches,
+ addEventListener() {},
+ });
+}
+
+function button() {
+ let click;
+ return {
+ attributes: {},
+ textContent: "",
+ addEventListener(event, handler) {
+ if (event === "click") click = handler;
+ },
+ click() {
+ click();
+ },
+ setAttribute(name, value) {
+ this.attributes[name] = value;
+ },
+ };
+}
+
+test("resolveTheme uses a saved user preference before system preference", () => {
+ assert.equal(
+ resolveTheme({
+ storage: storage({ "taskforge.theme": "light" }),
+ matchMedia: matchMedia(true),
+ }),
+ "light",
+ );
+});
+
+test("resolveTheme falls back to the system preference on first load", () => {
+ assert.equal(
+ resolveTheme({ storage: storage(), matchMedia: matchMedia(true) }),
+ "dark",
+ );
+ assert.equal(
+ resolveTheme({ storage: storage(), matchMedia: matchMedia(false) }),
+ "light",
+ );
+});
+
+test("getStoredTheme ignores invalid stored values", () => {
+ assert.equal(getStoredTheme(storage({ "taskforge.theme": "sepia" })), null);
+});
+
+test("saveTheme persists only supported themes", () => {
+ const target = storage();
+ saveTheme("dark", target);
+ saveTheme("sepia", target);
+ assert.equal(target.getItem("taskforge.theme"), "dark");
+});
+
+test("applyTheme updates the document theme and color scheme", () => {
+ const root = { dataset: {}, style: {} };
+ assert.equal(applyTheme("dark", root), "dark");
+ assert.deepEqual(root, {
+ dataset: { theme: "dark" },
+ style: { colorScheme: "dark" },
+ });
+});
+
+test("getNextTheme toggles between light and dark", () => {
+ assert.equal(getNextTheme("dark"), "light");
+ assert.equal(getNextTheme("light"), "dark");
+});
+
+test("initThemeToggle renders, toggles, and persists the user choice", () => {
+ const root = { dataset: {}, style: {} };
+ const target = storage();
+ const toggle = button();
+
+ initThemeToggle({
+ button: toggle,
+ storage: target,
+ matchMedia: matchMedia(true),
+ root,
+ });
+
+ assert.equal(root.dataset.theme, "dark");
+ assert.equal(toggle.textContent, "Light mode");
+ assert.equal(toggle.attributes["aria-pressed"], "true");
+
+ toggle.click();
+
+ assert.equal(root.dataset.theme, "light");
+ assert.equal(root.style.colorScheme, "light");
+ assert.equal(target.getItem("taskforge.theme"), "light");
+ assert.equal(toggle.textContent, "Dark mode");
+ assert.equal(toggle.attributes["aria-pressed"], "false");
+});