From c90c0ddf9d6e3822e32a0746c56816276275c42a Mon Sep 17 00:00:00 2001 From: minhtrannhat Date: Sun, 8 Nov 2026 15:10:12 -0500 Subject: [PATCH] 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 --- README.md | 2 +- src/client/index.html | 15 +++++-- src/client/main.ts | 11 ++--- src/client/manifest.webmanifest | 4 +- src/client/styles.css | 23 +++++++++- src/client/theme.ts | 9 ++++ tests/theme.test.ts | 74 +++++++++++++++++++++++++++++++++ 7 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 src/client/theme.ts create mode 100644 tests/theme.test.ts diff --git a/README.md b/README.md index 9a678c2..03bb94a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/client/index.html b/src/client/index.html index 8785a34..cdecd2d 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -3,7 +3,7 @@ - + @@ -12,10 +12,17 @@ diff --git a/src/client/main.ts b/src/client/main.ts index ef72679..2689d2f 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -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 `
${pageHeading("Preferences.", "KEEP IT SIMPLE", "// SYSTEM")}
// DATA & DEVICEPRIVATE
-

Appearance

Catppuccin colors. Saved on this device.

+

Appearance

Nord and Catppuccin colors. Saved on this device.

${THEMES.map(option => ``).join("")}

Forest CSV import

Bring in your existing sessions. Re-importing the same file skips duplicates.

Forest CSV export

Download your completed sessions with Forest's six-column layout.

Completion notifications

${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."}

@@ -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; diff --git a/src/client/manifest.webmanifest b/src/client/manifest.webmanifest index 0c5233a..2f1c8f0 100644 --- a/src/client/manifest.webmanifest +++ b/src/client/manifest.webmanifest @@ -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" }, diff --git a/src/client/styles.css b/src/client/styles.css index b2fb968..f29d9de 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -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"] { diff --git a/src/client/theme.ts b/src/client/theme.ts new file mode 100644 index 0000000..1a664d0 --- /dev/null +++ b/src/client/theme.ts @@ -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 = { nord: "#2e3440", mocha: "#1e1e2e", latte: "#e6e9ef" }; + +export function isTheme(value: unknown): value is Theme { + return THEMES.includes(value as Theme); +} diff --git a/tests/theme.test.ts b/tests/theme.test.ts new file mode 100644 index 0000000..8e7ad1b --- /dev/null +++ b/tests/theme.test.ts @@ -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) { + const script = html.match(/