Add the Nord theme and make it the default

The theme picker shows NORD, MOCHA and LATTE. Nord is the base theme
in the CSS, the browser bar color and the PWA manifest. The startup
script applies the theme before the first paint.

A device that saved Mocha changes to Nord one time. After that, a user
can select Mocha again and the app keeps it.

The Nord red #bf616a has only 3.05:1 contrast on the background, so the
danger color is the lighter #dc8890 (4.74:1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
minhtrannhatandClaude Opus 5.5 committed 2026-11-08 15:10:12 -05:00
1 parent 9fba5de6d7
commit c90c0ddf9d
7 files changed
+125 -13

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# Timelog
A private, minimal time log with a duration-first timer, offline PWA, Forest CSV import/export, and time trends by day, week, month, year, and tag. The terminal-style UI offers Catppuccin Mocha and Latte themes, with the choice saved on each device. There is no tree or streak system.
A private, minimal time log with a duration-first timer, offline PWA, Forest CSV import/export, and time trends by day, week, month, year, and tag. The terminal-style UI offers Nord (the default) and Catppuccin Mocha and Latte themes, with the choice saved on each device. There is no tree or streak system.
## Run locally
+11 -4
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#1e1e2e" />
<meta name="theme-color" content="#2e3440" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="description" content="A quiet place to log your time." />
@@ -12,10 +12,17 @@
<link rel="apple-touch-icon" href="/icon-180.png" />
<script>
try {
if (localStorage.getItem("timelog-theme") === "latte") {
document.documentElement.dataset.theme = "latte";
document.querySelector('meta[name="theme-color"]').setAttribute("content", "#e6e9ef");
// Keep these colors the same as THEME_COLORS in src/client/theme.ts.
var colors = { nord: "#2e3440", mocha: "#1e1e2e", latte: "#e6e9ef" };
var theme = localStorage.getItem("timelog-theme");
// Nord replaced Mocha as the default. Change a saved Mocha to Nord one time only.
if (!localStorage.getItem("timelog-theme-nord")) {
if (theme === "mocha") localStorage.setItem("timelog-theme", (theme = "nord"));
localStorage.setItem("timelog-theme-nord", "1");
}
if (!colors.hasOwnProperty(theme)) theme = "nord";
document.documentElement.dataset.theme = theme;
document.querySelector('meta[name="theme-color"]').setAttribute("content", colors[theme]);
} catch {}
</script>
<link rel="stylesheet" href="/styles.css?v=__BUILD_ID__" />
+6 -5
View File
@@ -6,15 +6,16 @@ import { dueMs, recordEnd, type TimeRecord } from "../shared/record.ts";
import { normalizeTag } from "../shared/tag.ts";
import { countdown, pad, pageTitle } from "./title.ts";
import { readyToReload } from "./update.ts";
import { THEMES, THEME_COLORS, isTheme, type Theme } from "./theme.ts";
type View = "timer" | "log" | "insights" | "settings";
type Theme = "latte" | "mocha";
type Me = { authenticated: boolean; dev: boolean; githubConfigured: boolean; vapidPublicKey: string | null };
const app = document.getElementById("app")!;
let records: TimeRecord[] = [];
let view: View = "timer";
let theme: Theme = document.documentElement.dataset.theme === "latte" ? "latte" : "mocha";
const startTheme = document.documentElement.dataset.theme;
let theme: Theme = isTheme(startTheme) ? startTheme : "nord";
let me: Me | null = null;
let syncStatus: SyncStatus = navigator.onLine ? "pending" : "offline";
let selectedTag = normalizeTag(localStorage.getItem("timelog-last-tag") || "Study");
@@ -65,7 +66,7 @@ function markSignedOut() {
function setTheme(next: Theme) {
theme = next;
document.documentElement.dataset.theme = next;
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", next === "latte" ? "#e6e9ef" : "#1e1e2e");
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", THEME_COLORS[next]);
localStorage.setItem("timelog-theme", next);
render();
}
@@ -232,7 +233,7 @@ function settingsPage() {
const permission = "Notification" in window ? Notification.permission : "unsupported";
return `<section class="page">${pageHeading("Preferences.", "KEEP IT SIMPLE", "// SYSTEM")}
<div class="panel"><div class="panel-inner"><div class="panel-label"><span>// DATA &amp; DEVICE</span><span class="end">PRIVATE</span></div>
<div class="settings-row"><div><h3>Appearance</h3><p>Catppuccin colors. Saved on this device.</p></div><div class="theme-toggle" role="group" aria-label="Color theme"><button class="theme-option ${theme === "latte" ? "active" : ""}" data-action="theme" data-theme="latte" aria-pressed="${theme === "latte"}" type="button">LATTE</button><button class="theme-option ${theme === "mocha" ? "active" : ""}" data-action="theme" data-theme="mocha" aria-pressed="${theme === "mocha"}" type="button">MOCHA</button></div></div>
<div class="settings-row"><div><h3>Appearance</h3><p>Nord and Catppuccin colors. Saved on this device.</p></div><div class="theme-toggle" role="group" aria-label="Color theme">${THEMES.map(option => `<button class="theme-option ${theme === option ? "active" : ""}" data-action="theme" data-theme="${option}" aria-pressed="${theme === option}" type="button">${option.toUpperCase()}</button>`).join("")}</div></div>
<div class="settings-row"><div><h3>Forest CSV import</h3><p>Bring in your existing sessions. Re-importing the same file skips duplicates.</p></div><button class="small-btn" data-action="import" type="button">IMPORT CSV</button><input class="hidden-input" id="csv-file" type="file" accept=".csv,text/csv" /></div>
<div class="settings-row"><div><h3>Forest CSV export</h3><p>Download your completed sessions with Forest's six-column layout.</p></div><button class="small-btn" data-action="export" type="button">EXPORT CSV</button></div>
<div class="settings-row"><div><h3>Completion notifications</h3><p>${permission === "denied" ? "Blocked in your device settings." : !canNotify ? "On iPhone, add Timelog to your Home Screen, then open it there to enable alerts." : permission === "granted" ? "Permission granted on this device. Reconnect if alerts stop arriving." : "Get an alert when a session reaches its duration. On iPhone, open Timelog from your Home Screen first."}</p></div><button class="small-btn" data-action="notify" type="button" ${!canNotify || !pushRegistration || permission === "denied" ? "disabled" : ""}>${permission === "granted" ? "RECONNECT" : "ENABLE"}</button></div>
@@ -428,7 +429,7 @@ async function dispatch(action: string, element: HTMLElement) {
case "prev-insight": insightOffset--; render(); break;
case "next-insight": insightOffset++; render(); break;
case "range": insightKind = element.dataset.range as RangeKind; insightOffset = 0; render(); break;
case "theme": setTheme(element.dataset.theme === "latte" ? "latte" : "mocha"); break;
case "theme": setTheme(isTheme(element.dataset.theme) ? element.dataset.theme : "nord"); break;
case "add": editingId = "new"; render(); break;
case "edit": editingId = element.dataset.id || null; render(); break;
case "close-modal": editingId = null; render(); break;
+2 -2
View File
@@ -6,8 +6,8 @@
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#1e1e2e",
"theme_color": "#1e1e2e",
"background_color": "#2e3440",
"theme_color": "#2e3440",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
+22 -1
View File
@@ -1,4 +1,26 @@
:root {
color-scheme: dark;
--bg: #2e3440;
--panel: #272c36;
--panel-soft: #3b4252;
--field-bg: #242933;
--ink: #eceff4;
--muted: #d8dee9;
--faint: #b6bfcd;
--line: #616e88;
--line-bright: #7b88a1;
--accent: #88c0d0;
--accent-ink: #2e3440;
--chart: #8fbcbb;
--good: #a3be8c;
/* Nord red #bf616a, made lighter for 4.5:1 text contrast. */
--danger: #dc8890;
--scrim: #242933cc;
--shadow: #24293399;
--font: "SFMono-Regular", "Cascadia Code", "IBM Plex Mono", "DejaVu Sans Mono", ui-monospace, monospace;
}
:root[data-theme="mocha"] {
color-scheme: dark;
--bg: #1e1e2e;
--panel: #181825;
@@ -16,7 +38,6 @@
--danger: #f38ba8;
--scrim: #11111bcc;
--shadow: #11111b99;
--font: "SFMono-Regular", "Cascadia Code", "IBM Plex Mono", "DejaVu Sans Mono", ui-monospace, monospace;
}
:root[data-theme="latte"] {
+9
View File
@@ -0,0 +1,9 @@
export const THEMES = ["nord", "mocha", "latte"] as const;
export type Theme = (typeof THEMES)[number];
/** The page background of each theme, also used for the browser bar. index.html has a copy. */
export const THEME_COLORS: Record<Theme, string> = { nord: "#2e3440", mocha: "#1e1e2e", latte: "#e6e9ef" };
export function isTheme(value: unknown): value is Theme {
return THEMES.includes(value as Theme);
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, test } from "bun:test";
import { THEMES, THEME_COLORS, isTheme } from "../src/client/theme.ts";
const html = await Bun.file("src/client/index.html").text();
const css = await Bun.file("src/client/styles.css").text();
const manifest = await Bun.file("src/client/manifest.webmanifest").json();
/** Runs the inline startup script from index.html with a fake browser. */
function startup(saved: Record<string, string>) {
const script = html.match(/<script>([\s\S]*?)<\/script>/)![1]!;
const storage = new Map(Object.entries(saved));
const root = { dataset: {} as Record<string, string> };
let themeColor = "";
const localStorage = {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, value); },
};
const document = {
documentElement: root,
querySelector: () => ({ setAttribute: (_: string, value: string) => { themeColor = value; } }),
};
new Function("localStorage", "document", script)(localStorage, document);
return { theme: root.dataset.theme, themeColor, saved: storage.get("timelog-theme") };
}
describe("startup theme", () => {
test("a device with no saved theme gets Nord", () => {
expect(startup({})).toMatchObject({ theme: "nord", themeColor: THEME_COLORS.nord });
});
test("a device that saved Mocha changes to Nord one time", () => {
expect(startup({ "timelog-theme": "mocha" })).toEqual({ theme: "nord", themeColor: THEME_COLORS.nord, saved: "nord" });
});
test("Mocha stays when the user selects it after the change to Nord", () => {
expect(startup({ "timelog-theme": "mocha", "timelog-theme-nord": "1" }))
.toMatchObject({ theme: "mocha", themeColor: THEME_COLORS.mocha });
});
test("Latte stays Latte", () => {
expect(startup({ "timelog-theme": "latte" })).toMatchObject({ theme: "latte", themeColor: THEME_COLORS.latte });
});
test("an unknown saved value gets Nord", () => {
expect(startup({ "timelog-theme": "solarized" })).toMatchObject({ theme: "nord", themeColor: THEME_COLORS.nord });
});
});
describe("theme colors", () => {
test("Nord is first in the picker", () => {
expect(THEMES[0]).toBe("nord");
expect(isTheme("nord") && isTheme("mocha") && isTheme("latte")).toBe(true);
expect(isTheme("solarized")).toBe(false);
});
test("the startup script uses the same colors as the app", () => {
for (const theme of THEMES) expect(html).toContain(`${theme}: "${THEME_COLORS[theme]}"`);
});
test("the CSS has Nord as the base and a block for each other theme", () => {
const base = css.match(/:root \{([\s\S]*?)\}/)![1]!;
expect(base).toContain(`--bg: ${THEME_COLORS.nord};`);
for (const theme of THEMES.filter(t => t !== "nord")) {
const block = css.match(new RegExp(`:root\\[data-theme="${theme}"\\] \\{([\\s\\S]*?)\\}`))?.[1];
expect(block).toContain(`--bg: ${THEME_COLORS[theme]};`);
}
});
test("the first paint and the installed app use Nord", () => {
expect(html).toContain(`<meta name="theme-color" content="${THEME_COLORS.nord}" />`);
expect(manifest.theme_color).toBe(THEME_COLORS.nord);
expect(manifest.background_color).toBe(THEME_COLORS.nord);
});
});