diff --git a/README.md b/README.md
index 04e670c..2cceb93 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ A simple, community-maintained open-source task tracker. Add tasks, check them o
- Single-page, zero-build static web app
- Tasks persist in `localStorage` — no account, no server, no tracking
- Keyboard-friendly (more shortcuts coming, see #5)
-- Light & dark themes (dark coming, see #1)
+- Light & dark themes with a persistent user preference
- MIT licensed
## Quick start
diff --git a/index.html b/index.html
index 7309515..be1e0ca 100644
--- a/index.html
+++ b/index.html
@@ -1,15 +1,34 @@
-
+
+
TaskForge — open-source task tracker
+
diff --git a/src/app.js b/src/app.js
index fe0e9c1..5cc1b6a 100644
--- a/src/app.js
+++ b/src/app.js
@@ -5,9 +5,34 @@ 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");
+const themePreference = window.matchMedia("(prefers-color-scheme: dark)");
+const THEME_KEY = "taskforge.theme";
let tasks = load();
+function getStoredTheme() {
+ const theme = window.localStorage.getItem(THEME_KEY);
+ return theme === "dark" || theme === "light" ? theme : null;
+}
+
+function getActiveTheme() {
+ return getStoredTheme() ?? (themePreference.matches ? "dark" : "light");
+}
+
+function updateThemeToggle() {
+ const activeTheme = getActiveTheme();
+ const isDark = activeTheme === "dark";
+ themeToggle.textContent = isDark ? "Light mode" : "Dark mode";
+ themeToggle.setAttribute("aria-pressed", String(isDark));
+}
+
+function setTheme(theme) {
+ window.localStorage.setItem(THEME_KEY, theme);
+ document.documentElement.dataset.theme = theme;
+ updateThemeToggle();
+}
+
function render() {
list.innerHTML = "";
if (tasks.length === 0) {
@@ -60,4 +85,15 @@ form.addEventListener("submit", (e) => {
render();
});
+themeToggle.addEventListener("click", () => {
+ setTheme(getActiveTheme() === "dark" ? "light" : "dark");
+});
+
+themePreference.addEventListener("change", () => {
+ if (!getStoredTheme()) {
+ updateThemeToggle();
+ }
+});
+
+updateThemeToggle();
render();
diff --git a/styles.css b/styles.css
index 2fa1e18..3925eda 100644
--- a/styles.css
+++ b/styles.css
@@ -1,12 +1,49 @@
:root {
--bg: #ffffff;
+ --surface: #ffffff;
--fg: #1f2328;
--muted: #6e7781;
--border: #d0d7de;
--accent: #0969da;
--accent-fg: #ffffff;
--done: #8c959f;
+ --danger: #cf222e;
+ --danger-bg: rgba(207, 34, 46, 0.08);
+ --shadow: rgba(31, 35, 40, 0.08);
--radius: 6px;
+ color-scheme: light;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --bg: #0d1117;
+ --surface: #161b22;
+ --fg: #e6edf3;
+ --muted: #8b949e;
+ --border: #30363d;
+ --accent: #2f81f7;
+ --accent-fg: #ffffff;
+ --done: #6e7681;
+ --danger: #ff7b72;
+ --danger-bg: rgba(248, 81, 73, 0.12);
+ --shadow: rgba(1, 4, 9, 0.3);
+ color-scheme: dark;
+ }
+}
+
+:root[data-theme="dark"] {
+ --bg: #0d1117;
+ --surface: #161b22;
+ --fg: #e6edf3;
+ --muted: #8b949e;
+ --border: #30363d;
+ --accent: #2f81f7;
+ --accent-fg: #ffffff;
+ --done: #6e7681;
+ --danger: #ff7b72;
+ --danger-bg: rgba(248, 81, 73, 0.12);
+ --shadow: rgba(1, 4, 9, 0.3);
+ color-scheme: dark;
}
* {
@@ -15,19 +52,27 @@
body {
margin: 0;
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
- sans-serif;
+ font-family:
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--fg);
line-height: 1.5;
}
.app-header {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
padding: 2rem 1.25rem 1rem;
text-align: center;
border-bottom: 1px solid var(--border);
}
+.header-copy {
+ min-width: 0;
+}
+
.app-header h1 {
margin: 0;
font-size: 1.75rem;
@@ -40,6 +85,29 @@ body {
font-size: 0.95rem;
}
+.theme-toggle {
+ position: absolute;
+ right: 1.25rem;
+ padding: 0.45rem 0.7rem;
+ font: inherit;
+ font-size: 0.85rem;
+ color: var(--fg);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: 0 1px 2px var(--shadow);
+ cursor: pointer;
+}
+
+.theme-toggle:hover {
+ border-color: var(--accent);
+}
+
+.theme-toggle:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
.app-main {
max-width: 36rem;
margin: 0 auto;
@@ -57,7 +125,7 @@ body {
padding: 0.5rem 0.75rem;
font: inherit;
color: inherit;
- background: var(--bg);
+ background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
@@ -96,6 +164,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 +191,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 +211,15 @@ body {
.app-footer a {
color: inherit;
}
+
+@media (max-width: 40rem) {
+ .app-header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .theme-toggle {
+ position: static;
+ align-self: center;
+ }
+}