Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 31 additions & 7 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TaskForge — open-source task tracker</title>
<script>
(() => {
const key = "taskforge.theme";
const stored = window.localStorage.getItem(key);
const theme =
stored === "dark" || stored === "light"
? stored
: window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
})();
</script>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
Expand All @@ -12,14 +26,24 @@
<h1>TaskForge</h1>
<p class="tagline">A tiny task list that lives in your browser.</p>
</div>
<div class="auth-panel" aria-live="polite">
<img id="auth-avatar" class="auth-avatar" hidden />
<span id="auth-status" class="auth-status">
Tasks are stored on this device.
</span>
<button id="auth-action" class="auth-action" type="button">
Sign in with GitHub
<div class="header-actions">
<button
id="theme-toggle"
class="theme-toggle"
type="button"
aria-pressed="false"
>
Dark theme
</button>
<div class="auth-panel" aria-live="polite">
<img id="auth-avatar" class="auth-avatar" hidden />
<span id="auth-status" class="auth-status">
Tasks are stored on this device.
</span>
<button id="auth-action" class="auth-action" type="button">
Sign in with GitHub
</button>
</div>
</div>
</header>

Expand Down
2 changes: 2 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -20,6 +21,7 @@ let tasks = [];
let user = null;

async function init() {
initThemeToggle();
const localTasks = loadLocal();
let completedSignIn = false;
try {
Expand Down
51 changes: 51 additions & 0 deletions src/theme.mjs
Original file line number Diff line number Diff line change
@@ -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";
}
74 changes: 68 additions & 6 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand All @@ -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;
Expand Down
35 changes: 35 additions & 0 deletions test/theme.test.js
Original file line number Diff line number Diff line change
@@ -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");
});