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 +
-

TaskForge

-

A tiny task list that lives in your browser.

+
+

TaskForge

+

A tiny task list that lives in your browser.

+
+
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"); +});