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(/