diff --git a/README.md b/README.md
index f1eba62..46bebaa 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ A simple, community-maintained open-source task tracker. Add tasks, check them o
- Tasks persist in `localStorage` by default, with optional GitHub sign-in sync
- GitHub OAuth uses PKCE in the browser and an HTTP-only session cookie
- Keyboard-friendly (more shortcuts coming, see #5)
-- Light & dark themes (dark coming, see #1)
+- Light and dark themes with a persisted preference
- MIT licensed
## Quick start
diff --git a/index.html b/index.html
index b6b00fb..5f27c94 100644
--- a/index.html
+++ b/index.html
@@ -4,6 +4,20 @@
TaskForge — open-source task tracker
+
@@ -12,14 +26,24 @@
TaskForge
A tiny task list that lives in your browser.
-
-
-
- Tasks are stored on this device.
-
-
- Sign in with GitHub
+
diff --git a/src/app.js b/src/app.js
index 46dd724..35285ad 100644
--- a/src/app.js
+++ b/src/app.js
@@ -6,6 +6,7 @@ import {
} from "./auth.js";
import { load, loadLocal, save } from "./storage.js";
import { createTask, toggleTask, removeTask } from "./tasks.js";
+import { initThemeToggle } from "./theme.mjs";
const form = document.getElementById("task-form");
const input = document.getElementById("task-input");
@@ -20,6 +21,7 @@ let tasks = [];
let user = null;
async function init() {
+ initThemeToggle();
const localTasks = loadLocal();
let completedSignIn = false;
try {
diff --git a/src/theme.mjs b/src/theme.mjs
new file mode 100644
index 0000000..d232270
--- /dev/null
+++ b/src/theme.mjs
@@ -0,0 +1,51 @@
+export const THEME_KEY = "taskforge.theme";
+
+const THEMES = new Set(["light", "dark"]);
+
+export function normalizeTheme(value) {
+ return THEMES.has(value) ? value : "";
+}
+
+export function resolveInitialTheme({ storedTheme, prefersDark = false } = {}) {
+ return normalizeTheme(storedTheme) || (prefersDark ? "dark" : "light");
+}
+
+export function nextTheme(currentTheme) {
+ return normalizeTheme(currentTheme) === "dark" ? "light" : "dark";
+}
+
+export function applyTheme(theme, root = document.documentElement) {
+ const normalized = normalizeTheme(theme) || "light";
+ root.dataset.theme = normalized;
+ root.style.colorScheme = normalized;
+ return normalized;
+}
+
+export function initThemeToggle({
+ button = document.getElementById("theme-toggle"),
+ root = document.documentElement,
+ storage = window.localStorage,
+ media = window.matchMedia("(prefers-color-scheme: dark)"),
+} = {}) {
+ let theme = applyTheme(
+ resolveInitialTheme({
+ storedTheme: storage.getItem(THEME_KEY),
+ prefersDark: media.matches,
+ }),
+ root,
+ );
+
+ syncButton(button, theme);
+
+ button.addEventListener("click", () => {
+ theme = applyTheme(nextTheme(theme), root);
+ storage.setItem(THEME_KEY, theme);
+ syncButton(button, theme);
+ });
+}
+
+function syncButton(button, theme) {
+ const isDark = theme === "dark";
+ button.setAttribute("aria-pressed", String(isDark));
+ button.textContent = isDark ? "Light theme" : "Dark theme";
+}
diff --git a/styles.css b/styles.css
index a6c7afc..30152e2 100644
--- a/styles.css
+++ b/styles.css
@@ -3,12 +3,36 @@
--fg: #1f2328;
--muted: #6e7781;
--border: #d0d7de;
+ --surface: #f6f8fa;
+ --control-bg: #ffffff;
+ --danger: #cf222e;
+ --danger-bg: rgba(207, 34, 46, 0.08);
+ --notice-fg: #7d4e00;
+ --notice-bg: #fff8c5;
+ --notice-border: #d4a72c;
--accent: #0969da;
--accent-fg: #ffffff;
--done: #8c959f;
--radius: 6px;
}
+[data-theme="dark"] {
+ --bg: #111315;
+ --fg: #f0f3f6;
+ --muted: #a7b0bb;
+ --border: #3e464f;
+ --surface: #1b1f24;
+ --control-bg: #161a1f;
+ --danger: #ff7b72;
+ --danger-bg: rgba(255, 123, 114, 0.16);
+ --notice-fg: #f7d774;
+ --notice-bg: #2e2611;
+ --notice-border: #8a6a1e;
+ --accent: #2f81f7;
+ --accent-fg: #ffffff;
+ --done: #8b949e;
+}
+
* {
box-sizing: border-box;
}
@@ -43,6 +67,29 @@ body {
font-size: 0.95rem;
}
+.header-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.75rem;
+}
+
+.theme-toggle {
+ flex: 0 0 auto;
+ min-width: 6.75rem;
+ 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 {
+ border-color: var(--accent);
+}
+
.auth-panel {
display: flex;
align-items: center;
@@ -89,9 +136,9 @@ body {
.auth-message {
margin: 0 0 1rem;
padding: 0.6rem 0.75rem;
- color: #7d4e00;
- background: #fff8c5;
- border: 1px solid #d4a72c;
+ color: var(--notice-fg);
+ background: var(--notice-bg);
+ border: 1px solid var(--notice-border);
border-radius: var(--radius);
}
@@ -106,7 +153,7 @@ body {
padding: 0.5rem 0.75rem;
font: inherit;
color: inherit;
- background: var(--bg);
+ background: var(--control-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
}
@@ -145,10 +192,15 @@ body {
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.75rem;
+ background: var(--control-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
}
+.task-item input[type="checkbox"] {
+ accent-color: var(--accent);
+}
+
.task-item.done .task-title {
text-decoration: line-through;
color: var(--done);
@@ -171,8 +223,8 @@ body {
}
.task-delete:hover {
- color: #cf222e;
- background: rgba(207, 34, 46, 0.08);
+ color: var(--danger);
+ background: var(--danger-bg);
}
.empty-state {
@@ -198,6 +250,16 @@ body {
flex-direction: column;
}
+ .header-actions {
+ align-items: stretch;
+ flex-direction: column;
+ width: 100%;
+ }
+
+ .theme-toggle {
+ align-self: flex-start;
+ }
+
.auth-panel {
justify-content: flex-start;
min-width: 0;
diff --git a/test/theme.test.js b/test/theme.test.js
new file mode 100644
index 0000000..bce4391
--- /dev/null
+++ b/test/theme.test.js
@@ -0,0 +1,35 @@
+const assert = require("node:assert/strict");
+const test = require("node:test");
+
+test("theme helpers resolve persisted and system preferences", async () => {
+ const { nextTheme, normalizeTheme, resolveInitialTheme } = await import(
+ "../src/theme.mjs"
+ );
+
+ assert.equal(normalizeTheme("dark"), "dark");
+ assert.equal(normalizeTheme("sepia"), "");
+ assert.equal(
+ resolveInitialTheme({ storedTheme: "light", prefersDark: true }),
+ "light",
+ );
+ assert.equal(resolveInitialTheme({ storedTheme: "", prefersDark: true }), "dark");
+ assert.equal(resolveInitialTheme({ prefersDark: false }), "light");
+ assert.equal(nextTheme("dark"), "light");
+ assert.equal(nextTheme("light"), "dark");
+});
+
+test("applyTheme writes the root theme and color scheme", async () => {
+ const { applyTheme } = await import("../src/theme.mjs");
+ const root = {
+ dataset: {},
+ style: {},
+ };
+
+ assert.equal(applyTheme("dark", root), "dark");
+ assert.equal(root.dataset.theme, "dark");
+ assert.equal(root.style.colorScheme, "dark");
+
+ assert.equal(applyTheme("invalid", root), "light");
+ assert.equal(root.dataset.theme, "light");
+ assert.equal(root.style.colorScheme, "light");
+});