diff --git a/eslint.config.mjs b/eslint.config.mjs index 3c8ee53..64649cb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,7 +1,10 @@ const browserGlobals = { + btoa: "readonly", crypto: "readonly", document: "readonly", fetch: "readonly", + localStorage: "readonly", + sessionStorage: "readonly", TextEncoder: "readonly", URL: "readonly", URLSearchParams: "readonly", @@ -11,10 +14,14 @@ const browserGlobals = { const nodeGlobals = { Buffer: "readonly", console: "readonly", - module: "readonly", + fetch: "readonly", process: "readonly", - require: "readonly", - __dirname: "readonly", +}; + +const testGlobals = { + ...nodeGlobals, + Map: "readonly", + Set: "readonly", }; export default [ @@ -22,8 +29,12 @@ export default [ files: ["server.js", "test/**/*.js"], languageOptions: { ecmaVersion: 2023, - sourceType: "commonjs", - globals: nodeGlobals, + sourceType: "module", + globals: testGlobals, + }, + rules: { + "no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + semi: ["error", "always"], }, }, { @@ -33,5 +44,9 @@ export default [ sourceType: "module", globals: browserGlobals, }, + rules: { + "no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + semi: ["error", "always"], + }, }, ]; diff --git a/index.html b/index.html index b6b00fb..a32ffba 100644 --- a/index.html +++ b/index.html @@ -1,9 +1,31 @@ - + TaskForge — open-source task tracker + @@ -12,13 +34,23 @@

TaskForge

A tiny task list that lives in your browser.

-
- - - Tasks are stored on this device. - - +
+ diff --git a/package.json b/package.json index ad70b6b..97fb750 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,11 @@ "version": "0.1.0", "description": "A simple, community-maintained open-source task tracker.", "private": true, + "type": "module", "scripts": { "lint": "eslint server.js src test", "test": "node --test", - "format": "prettier --write 'src/**/*.js' 'index.html' 'styles.css'", + "format": "prettier --write 'src/**/*.js' 'test/**/*.js' 'index.html' 'styles.css' 'eslint.config.mjs'", "start": "node server.js" }, "devDependencies": { diff --git a/server.js b/server.js index 695407f..50077bb 100644 --- a/server.js +++ b/server.js @@ -1,7 +1,11 @@ -const crypto = require("node:crypto"); -const fs = require("node:fs/promises"); -const http = require("node:http"); -const path = require("node:path"); +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import http from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); const HOST = process.env.HOST || "127.0.0.1"; const PORT = Number(process.env.PORT || 8000); @@ -22,7 +26,7 @@ const CONTENT_TYPES = { const sessions = new Map(); -function createServer(options = {}) { +export function createServer(options = {}) { const dataFile = options.dataFile || DATA_FILE; const fetchImpl = options.fetchImpl || global.fetch; const sessionStore = options.sessionStore || sessions; @@ -254,7 +258,7 @@ async function readJson(req) { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } -function sanitizeTasks(tasks) { +export function sanitizeTasks(tasks) { if (!Array.isArray(tasks)) return []; return tasks .filter((task) => task && typeof task === "object") @@ -311,14 +315,10 @@ function sendText(res, statusCode, text) { res.end(text); } -if (require.main === module) { +if (process.argv[1] === __filename) { createServer().listen(PORT, HOST, () => { console.log(`TaskForge listening at http://${HOST}:${PORT}`); }); } -module.exports = { - SESSION_COOKIE, - createServer, - sanitizeTasks, -}; +export { SESSION_COOKIE }; diff --git a/src/app.js b/src/app.js index 46dd724..18fe5ec 100644 --- a/src/app.js +++ b/src/app.js @@ -6,11 +6,13 @@ import { } from "./auth.js"; import { load, loadLocal, save } from "./storage.js"; import { createTask, toggleTask, removeTask } from "./tasks.js"; +import { initThemeToggle } from "./theme.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"); const authStatus = document.getElementById("auth-status"); const authAction = document.getElementById("auth-action"); const authAvatar = document.getElementById("auth-avatar"); @@ -44,6 +46,8 @@ async function init() { render(); } +initThemeToggle({ button: themeToggle }); + function render() { list.innerHTML = ""; if (tasks.length === 0) { 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/src/export.js b/src/export.js index 3844c7d..d756903 100644 --- a/src/export.js +++ b/src/export.js @@ -3,7 +3,9 @@ function todayStamp() { } export function exportJson(tasks) { - const blob = new Blob([JSON.stringify(tasks, null, 2)], { type: "application/json" }); + const blob = new Blob([JSON.stringify(tasks, null, 2)], { + type: "application/json", + }); download(blob, `taskforge-export-${todayStamp()}.json`); } 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 a6c7afc..ef4c98d 100644 --- a/styles.css +++ b/styles.css @@ -3,20 +3,40 @@ --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; } 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; @@ -43,6 +63,34 @@ 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.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; +} + .auth-panel { display: flex; align-items: center; @@ -145,6 +193,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); } @@ -171,8 +220,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 +247,12 @@ body { flex-direction: column; } + .header-actions { + align-items: stretch; + flex-direction: column; + width: 100%; + } + .auth-panel { justify-content: flex-start; min-width: 0; diff --git a/test/server.test.js b/test/server.test.js index c3b9f65..4401486 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -1,11 +1,11 @@ -const assert = require("node:assert/strict"); -const fs = require("node:fs/promises"); -const http = require("node:http"); -const os = require("node:os"); -const path = require("node:path"); -const test = require("node:test"); - -const { SESSION_COOKIE, createServer, sanitizeTasks } = require("../server.js"); +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createServer, sanitizeTasks } from "../server.js"; test("sanitizeTasks drops malformed rows and trims persisted fields", () => { assert.deepEqual( 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"); +});