diff --git a/README.md b/README.md index f1eba62..9117d86 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 & dark themes with persisted user preference - MIT licensed ## Quick start diff --git a/eslint.config.mjs b/eslint.config.mjs index 3c8ee53..3b5ad78 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,7 +1,9 @@ const browserGlobals = { + Blob: "readonly", crypto: "readonly", document: "readonly", fetch: "readonly", + HTMLElement: "readonly", TextEncoder: "readonly", URL: "readonly", URLSearchParams: "readonly", diff --git a/index.html b/index.html index b6b00fb..671df3d 100644 --- a/index.html +++ b/index.html @@ -1,9 +1,19 @@ - + TaskForge — open-source task tracker + @@ -12,14 +22,24 @@

TaskForge

A tiny task list that lives in your browser.

-
- - - Tasks are stored on this device. - - +
+ + + Tasks are stored on this device. + + +
diff --git a/server.js b/server.js index 695407f..38b0b91 100644 --- a/server.js +++ b/server.js @@ -8,7 +8,8 @@ const PORT = Number(process.env.PORT || 8000); const PUBLIC_DIR = __dirname; const PUBLIC_ROOT = `${PUBLIC_DIR}${path.sep}`; const DATA_FILE = - process.env.TASKFORGE_DATA_FILE || path.join(__dirname, ".taskforge-data.json"); + process.env.TASKFORGE_DATA_FILE || + path.join(__dirname, ".taskforge-data.json"); const SESSION_COOKIE = "taskforge_session"; const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; diff --git a/src/app.js b/src/app.js index 46dd724..7074977 100644 --- a/src/app.js +++ b/src/app.js @@ -11,6 +11,9 @@ 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 themeMedia = window.matchMedia("(prefers-color-scheme: dark)"); +const themeStorageKey = "taskforge.theme"; const authStatus = document.getElementById("auth-status"); const authAction = document.getElementById("auth-action"); const authAvatar = document.getElementById("auth-avatar"); @@ -44,6 +47,21 @@ async function init() { render(); } +function getSystemTheme() { + return themeMedia.matches ? "dark" : "light"; +} + +function getSavedTheme() { + return window.localStorage.getItem(themeStorageKey); +} + +function applyTheme(theme) { + document.documentElement.dataset.theme = theme; + const isDark = theme === "dark"; + themeToggle.textContent = isDark ? "Light mode" : "Dark mode"; + themeToggle.setAttribute("aria-pressed", String(isDark)); +} + function render() { list.innerHTML = ""; if (tasks.length === 0) { @@ -72,7 +90,7 @@ function render() { const del = document.createElement("button"); del.type = "button"; del.className = "task-delete"; - del.textContent = "✕"; + del.textContent = "x"; del.setAttribute("aria-label", `Delete task: ${task.title}`); del.addEventListener("click", async () => { tasks = removeTask(tasks, task.id); @@ -85,6 +103,19 @@ function render() { } } +themeToggle.addEventListener("click", () => { + const nextTheme = + document.documentElement.dataset.theme === "dark" ? "light" : "dark"; + window.localStorage.setItem(themeStorageKey, nextTheme); + applyTheme(nextTheme); +}); + +themeMedia.addEventListener("change", () => { + if (!getSavedTheme()) { + applyTheme(getSystemTheme()); + } +}); + form.addEventListener("submit", async (e) => { e.preventDefault(); const title = input.value.trim(); @@ -146,4 +177,5 @@ function mergeTasks(localTasks, remoteTasks) { return [...byId.values()].sort((a, b) => b.createdAt - a.createdAt); } +applyTheme(getSavedTheme() || getSystemTheme()); init(); diff --git a/src/auth.js b/src/auth.js index d14e7b4..a01085d 100644 --- a/src/auth.js +++ b/src/auth.js @@ -20,7 +20,8 @@ export async function startGitHubSignIn() { const codeVerifier = randomString(64); const codeChallenge = await pkceChallenge(codeVerifier); const redirectUri = - config.redirectUri || `${window.location.origin}${window.location.pathname}`; + config.redirectUri || + `${window.location.origin}${window.location.pathname}`; window.sessionStorage.setItem(AUTH_STATE_KEY, state); window.sessionStorage.setItem(CODE_VERIFIER_KEY, codeVerifier); diff --git a/styles.css b/styles.css index a6c7afc..82858e8 100644 --- a/styles.css +++ b/styles.css @@ -6,7 +6,31 @@ --accent: #0969da; --accent-fg: #ffffff; --done: #8c959f; + --surface: #f6f8fa; + --danger: #cf222e; + --danger-bg: rgba(207, 34, 46, 0.08); + --warning-bg: #fff8c5; + --warning-border: #d4a72c; + --warning-fg: #7d4e00; --radius: 6px; + color-scheme: light; +} + +:root[data-theme="dark"] { + --bg: #0d1117; + --fg: #f0f6fc; + --muted: #8b949e; + --border: #30363d; + --accent: #58a6ff; + --accent-fg: #0d1117; + --done: #6e7681; + --surface: #161b22; + --danger: #ff7b72; + --danger-bg: rgba(255, 123, 114, 0.12); + --warning-bg: #332b00; + --warning-border: #8a6d1d; + --warning-fg: #f2cc60; + color-scheme: dark; } * { @@ -15,8 +39,8 @@ 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; @@ -28,9 +52,17 @@ body { justify-content: space-between; gap: 1rem; padding: 1.25rem; + background: var(--surface); border-bottom: 1px solid var(--border); } +.header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; +} + .app-header h1 { margin: 0; font-size: 1.75rem; @@ -89,9 +121,9 @@ body { .auth-message { margin: 0 0 1rem; padding: 0.6rem 0.75rem; - color: #7d4e00; - background: #fff8c5; - border: 1px solid #d4a72c; + color: var(--warning-fg); + background: var(--warning-bg); + border: 1px solid var(--warning-border); border-radius: var(--radius); } @@ -127,6 +159,21 @@ body { cursor: pointer; } +.theme-toggle { + padding: 0.45rem 0.75rem; + font: inherit; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + white-space: nowrap; +} + +.theme-toggle:hover { + border-color: var(--accent); +} + .task-form button:hover { filter: brightness(1.05); } @@ -147,6 +194,7 @@ body { padding: 0.6rem 0.75rem; border: 1px solid var(--border); border-radius: var(--radius); + background: var(--surface); } .task-item.done .task-title { @@ -171,8 +219,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 +246,12 @@ body { flex-direction: column; } + .header-actions { + align-items: flex-start; + flex-direction: column; + width: 100%; + } + .auth-panel { justify-content: flex-start; min-width: 0;