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. - - +
+ + + Tasks are stored on this device. + + +
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"); +});