feat: add persistent dashboard dark mode
CI/CD Dev / dev (push) Failing after 44s

This commit is contained in:
2026-08-15 00:53:58 +03:30
parent 675def5fe6
commit 32e7b6b749
8 changed files with 1423 additions and 1293 deletions
+9
View File
@@ -10,7 +10,16 @@ import (
//go:embed assets/favicon.ico
var favicon []byte
//go:embed assets/theme.js
var themeScript []byte
func (h *Handler) favicon(response http.ResponseWriter, request *http.Request) {
response.Header().Set("Cache-Control", "public, max-age=86400")
http.ServeContent(response, request, "favicon.ico", time.Time{}, bytes.NewReader(favicon))
}
func (h *Handler) theme(response http.ResponseWriter, request *http.Request) {
response.Header().Set("Cache-Control", "public, max-age=3600")
response.Header().Set("Content-Type", "application/javascript; charset=utf-8")
http.ServeContent(response, request, "theme.js", time.Time{}, bytes.NewReader(themeScript))
}
+54
View File
@@ -0,0 +1,54 @@
(() => {
const storageKey = "gl-theme";
const root = document.documentElement;
const storedTheme = () => {
try {
const value = localStorage.getItem(storageKey);
return value === "dark" || value === "light" ? value : "";
} catch (_) {
return "";
}
};
const systemTheme = () =>
window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
const updateControls = (theme) => {
document.querySelectorAll("[data-theme-toggle]").forEach((control) => {
const dark = theme === "dark";
control.setAttribute("aria-pressed", String(dark));
control.setAttribute("aria-label", dark ? control.dataset.lightLabel : control.dataset.darkLabel);
const icon = control.querySelector("[data-theme-icon]");
if (icon) icon.textContent = dark ? "☀" : "☾";
});
};
const applyTheme = (theme) => {
root.dataset.theme = theme;
root.style.colorScheme = theme;
updateControls(theme);
};
applyTheme(storedTheme() || systemTheme());
document.addEventListener("DOMContentLoaded", () => updateControls(root.dataset.theme));
document.addEventListener("htmx:afterSwap", () => updateControls(root.dataset.theme));
document.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const control = event.target.closest("[data-theme-toggle]");
if (!control) return;
const theme = root.dataset.theme === "dark" ? "light" : "dark";
try {
localStorage.setItem(storageKey, theme);
} catch (_) {
// The selected theme still applies for this page when storage is unavailable.
}
applyTheme(theme);
});
const media = window.matchMedia("(prefers-color-scheme: dark)");
media.addEventListener?.("change", () => {
if (!storedTheme()) applyTheme(systemTheme());
});
})();
+1
View File
@@ -61,6 +61,7 @@ type HolderPageData struct {
func NewHandler(service Explorer) *Handler {
handler := &Handler{explorer: service, routes: http.NewServeMux()}
handler.routes.HandleFunc("GET /favicon.ico", handler.favicon)
handler.routes.HandleFunc("GET /theme.js", handler.theme)
handler.routes.HandleFunc("GET /{$}", handler.dashboard)
handler.routes.HandleFunc("GET /assets", handler.assets)
handler.routes.HandleFunc("GET /transactions", handler.transaction)
+16 -1
View File
@@ -72,7 +72,7 @@ func TestDashboardRendersFullTemplPage(t *testing.T) {
NewHandler(service).ServeHTTP(response, request)
body := response.Body.String()
for _, expected := range []string{"<!doctype html>", "DARANO", "1,234", "abc123", "htmx.org@2.0.10", "class=\"mark\" src=\"/favicon.ico\"", "#F5F5F5", "#DFF1F1", "#BBD5DA", "#FF0000"} {
for _, expected := range []string{"<!doctype html>", "DARANO", "1,234", "abc123", "htmx.org@2.0.10", "src=\"/theme.js\"", "data-theme-toggle", "html[data-theme=\"dark\"]", "class=\"mark\" src=\"/favicon.ico\"", "#F5F5F5", "#DFF1F1", "#BBD5DA", "#FF0000"} {
if !strings.Contains(body, expected) {
t.Fatalf("response did not contain %q", expected)
}
@@ -183,6 +183,21 @@ func TestFaviconIsServedFromEmbeddedBrandAsset(t *testing.T) {
}
}
func TestThemeScriptIsServedFromEmbeddedAsset(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/theme.js", nil)
response := httptest.NewRecorder()
NewHandler(&explorerStub{}).ServeHTTP(response, request)
body := response.Body.String()
if response.Code != http.StatusOK || !strings.Contains(body, "localStorage") || !strings.Contains(body, "prefers-color-scheme") {
t.Fatalf("unexpected theme script response: status=%d body=%s", response.Code, body)
}
if !strings.Contains(response.Header().Get("Content-Type"), "javascript") {
t.Fatalf("unexpected theme script content type: %s", response.Header().Get("Content-Type"))
}
}
func TestTransactionHTMXRequestRendersOnlyExplorerContent(t *testing.T) {
service := &explorerStub{transaction: []ledger.Journal{{
ID: "journal-1", EffectKind: "withdrawal",
+4
View File
@@ -95,6 +95,8 @@ var english = map[string]string{
"assets": "Assets",
"accounts": "Accounts",
"read_only": "READ ONLY",
"dark_mode": "Use dark mode",
"light_mode": "Use light mode",
"general_ledger_explorer": "General ledger explorer",
"hero_line_one": "Every movement.",
"hero_line_two": "One durable record.",
@@ -194,6 +196,8 @@ var persian = map[string]string{
"assets": "دارایی‌ها",
"accounts": "حساب‌ها",
"read_only": "فقط خواندنی",
"dark_mode": "استفاده از حالت تیره",
"light_mode": "استفاده از حالت روشن",
"general_ledger_explorer": "کاوشگر دفتر کل",
"hero_line_one": "هر جابه‌جایی.",
"hero_line_two": "یک سابقه ماندگار.",
+18 -13
View File
@@ -17,12 +17,14 @@ templ Page(title string, active string, locale Locale, englishURL string, persia
<meta name="htmx-config" content='{"historyRestoreAsHxRequest":false,"selfRequestsOnly":true}'/>
<title>{ title } · { tr(locale, "site_name") }</title>
<link rel="icon" href="/favicon.ico" sizes="any"/>
<script src="/theme.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js" integrity="sha384-H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V" crossorigin="anonymous"></script>
<style>
:root { --ink:#1f2a2c; --muted:rgba(31,42,44,.68); --paper:#F5F5F5; --surface:#DFF1F1; --card:#ffffff; --line:rgba(31,42,44,.15); --teal:#BBD5DA; --accent:#FF0000; --shadow:0 18px 50px rgba(31,42,44,.10); }
:root { color-scheme:light; --ink:#1f2a2c; --on-ink:#ffffff; --muted:rgba(31,42,44,.68); --paper:#F5F5F5; --surface:#DFF1F1; --card:#ffffff; --glass:rgba(255,255,255,.78); --hover:rgba(223,241,241,.72); --line:rgba(31,42,44,.15); --teal:#BBD5DA; --accent:#FF0000; --positive:#497f82; --teal-glow:rgba(187,213,218,.48); --accent-glow:rgba(255,0,0,.04); --shadow:0 18px 50px rgba(31,42,44,.10); }
html[data-theme="dark"] { color-scheme:dark; --ink:#F5F5F5; --on-ink:#1f2a2c; --muted:rgba(223,241,241,.68); --paper:#101719; --surface:#253639; --card:#182326; --glass:rgba(24,35,38,.86); --hover:rgba(37,54,57,.82); --line:rgba(187,213,218,.18); --teal:#BBD5DA; --accent:#FF0000; --positive:#9bc8cc; --teal-glow:rgba(187,213,218,.12); --accent-glow:rgba(255,0,0,.08); --shadow:0 18px 50px rgba(0,0,0,.28); }
* { box-sizing:border-box; }
html { background:var(--paper); color:var(--ink); font-family:Inter,Tahoma,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
body { margin:0; min-height:100vh; background:radial-gradient(circle at 78% 0%,rgba(187,213,218,.48),transparent 30rem),radial-gradient(circle at 5% 30%,rgba(255,0,0,.04),transparent 24rem),var(--paper); }
body { margin:0; min-height:100vh; background:radial-gradient(circle at 78% 0%,var(--teal-glow),transparent 30rem),radial-gradient(circle at 5% 30%,var(--accent-glow),transparent 24rem),var(--paper); }
a { color:inherit; }
.shell { width:min(1180px,calc(100% - 40px)); margin:0 auto; }
.topbar { min-height:76px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); }
@@ -32,22 +34,24 @@ templ Page(title string, active string, locale Locale, englishURL string, persia
.nav { display:flex; gap:6px; align-items:center; }
.nav a { padding:10px 13px; border-radius:999px; text-decoration:none; color:var(--muted); font-size:13px; font-weight:700; }
.nav a:hover,.nav a.active { color:var(--ink); background:var(--surface); }
.live { display:flex; align-items:center; gap:8px; margin-inline-start:10px; padding:8px 11px; border:1px solid var(--line); border-radius:999px; font:700 11px/1 ui-monospace,SFMono-Regular,monospace; background:rgba(255,255,255,.62); }
.live { display:flex; align-items:center; gap:8px; margin-inline-start:10px; padding:8px 11px; border:1px solid var(--line); border-radius:999px; font:700 11px/1 ui-monospace,SFMono-Regular,monospace; background:var(--glass); }
.dot { width:7px; height:7px; background:var(--teal); border-radius:50%; box-shadow:0 0 0 4px rgba(187,213,218,.35); }
.language-switch { display:flex; padding:3px; margin-inline-start:8px; border:1px solid var(--line); border-radius:999px; background:rgba(255,255,255,.62); direction:ltr; }
.theme-toggle { width:34px; height:34px; flex:none; margin-inline-start:8px; border:1px solid var(--line); border-radius:50%; background:var(--glass); color:var(--ink); cursor:pointer; font-size:17px; line-height:1; }
.theme-toggle:hover { background:var(--surface); }
.language-switch { display:flex; padding:3px; margin-inline-start:8px; border:1px solid var(--line); border-radius:999px; background:var(--glass); direction:ltr; }
.language-switch a { padding:6px 8px; font:800 10px/1 ui-monospace,SFMono-Regular,monospace; }
.language-switch a.active { background:var(--ink); color:white; }
.language-switch a.active { background:var(--ink); color:var(--on-ink); }
main { padding:62px 0 90px; }
.eyebrow { display:flex; align-items:center; gap:9px; color:var(--teal); text-transform:uppercase; letter-spacing:.14em; font:800 11px/1 ui-monospace,SFMono-Regular,monospace; }
.eyebrow:before { content:""; width:26px; height:2px; background:var(--accent); }
h1 { max-width:830px; margin:20px 0 14px; font-size:clamp(42px,7vw,82px); line-height:.94; letter-spacing:-.065em; font-weight:850; }
.lede { max-width:660px; margin:0; color:var(--muted); font-size:17px; line-height:1.65; }
.search { display:flex; gap:10px; margin:34px 0 46px; padding:9px; border:1px solid var(--line); border-radius:16px; background:rgba(255,255,255,.88); box-shadow:var(--shadow); }
.search { display:flex; gap:10px; margin:34px 0 46px; padding:9px; border:1px solid var(--line); border-radius:16px; background:var(--glass); box-shadow:var(--shadow); }
.search input { flex:1; min-width:0; border:0; outline:0; background:transparent; padding:9px 12px; color:var(--ink); font:500 14px/1.4 ui-monospace,SFMono-Regular,monospace; }
.search button,.button { border:0; border-radius:10px; background:var(--accent); color:white; padding:13px 18px; font-weight:800; cursor:pointer; }
.search button:hover,.button:hover { background:var(--ink); }
.search button:hover,.button:hover { background:var(--ink); color:var(--on-ink); }
.stats { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin-bottom:46px; }
.stat { min-height:136px; padding:22px; border:1px solid var(--line); border-radius:16px; background:rgba(255,255,255,.78); }
.stat { min-height:136px; padding:22px; border:1px solid var(--line); border-radius:16px; background:var(--glass); }
.stat.accent { background:var(--accent); border-color:var(--accent); color:white; }
.stat.accent .stat-label { color:rgba(255,255,255,.78); }
.stat-label { color:var(--muted); text-transform:uppercase; letter-spacing:.13em; font:750 10px/1 ui-monospace,SFMono-Regular,monospace; }
@@ -62,7 +66,7 @@ templ Page(title string, active string, locale Locale, englishURL string, persia
th { padding:14px 18px; background:var(--surface); color:var(--ink); text-align:start; text-transform:uppercase; letter-spacing:.1em; font:750 9px/1 ui-monospace,SFMono-Regular,monospace; }
td { padding:17px 18px; border-top:1px solid var(--line); font-size:13px; vertical-align:middle; }
tr:first-child td { border-top:0; }
tr:hover td { background:rgba(223,241,241,.72); }
tr:hover td { background:var(--hover); }
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; direction:ltr; unicode-bidi:isolate; }
.hash-link { font-weight:750; text-decoration:none; border-bottom:1px solid var(--teal); }
.pill { display:inline-flex; padding:6px 9px; border-radius:999px; background:var(--surface); font:750 10px/1 ui-monospace,SFMono-Regular,monospace; text-transform:uppercase; }
@@ -74,7 +78,7 @@ templ Page(title string, active string, locale Locale, englishURL string, persia
.pagination a,.pagination span { min-width:42px; padding:10px 13px; border:1px solid var(--line); border-radius:10px; text-align:center; text-decoration:none; font:750 11px/1 ui-monospace,SFMono-Regular,monospace; }
.pagination a { background:var(--card); }
.pagination a:hover { background:var(--surface); }
.pagination .current { background:var(--ink); color:white; border-color:var(--ink); }
.pagination .current { background:var(--ink); color:var(--on-ink); border-color:var(--ink); }
.pagination .disabled { color:var(--muted); opacity:.45; }
.transaction-filters { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto auto; gap:10px; margin-bottom:14px; padding:14px; border:1px solid var(--line); border-radius:14px; background:var(--card); }
.transaction-filters .field input { height:40px; }
@@ -87,7 +91,7 @@ templ Page(title string, active string, locale Locale, englishURL string, persia
.journal { border:1px solid var(--line); border-radius:18px; background:var(--card); overflow:hidden; box-shadow:var(--shadow); }
.journal-head { padding:22px; display:flex; gap:20px; align-items:flex-start; justify-content:space-between; border-bottom:1px solid var(--line); }
.journal-head h2 { margin:8px 0 0; max-width:770px; overflow-wrap:anywhere; font:750 14px/1.5 ui-monospace,SFMono-Regular,monospace; }
.status { flex:none; padding:7px 10px; border-radius:999px; background:var(--teal); color:var(--ink); font:800 10px/1 ui-monospace,SFMono-Regular,monospace; }
.status { flex:none; padding:7px 10px; border-radius:999px; background:var(--teal); color:#1f2a2c; font:800 10px/1 ui-monospace,SFMono-Regular,monospace; }
.detail-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:1px; background:var(--line); border-bottom:1px solid var(--line); }
.detail { min-width:0; padding:18px 22px; background:var(--card); }
.detail label { display:block; margin-bottom:8px; color:var(--muted); text-transform:uppercase; letter-spacing:.1em; font:750 9px/1 ui-monospace,SFMono-Regular,monospace; }
@@ -100,13 +104,13 @@ templ Page(title string, active string, locale Locale, englishURL string, persia
.entry-account a { font-weight:700; text-decoration:none; }
.entry-account small { display:block; color:var(--muted); margin-top:4px; }
.amount { text-align:end; direction:ltr; font:800 13px/1 ui-monospace,SFMono-Regular,monospace; }
.amount.positive { color:#497f82; }
.amount.positive { color:var(--positive); }
.amount.negative { color:var(--accent); }
.notice { margin-top:28px; padding:20px; border:1px solid rgba(255,0,0,.32); border-radius:14px; background:rgba(255,0,0,.06); color:var(--ink); }
.notice strong { display:block; margin-bottom:5px; }
.account-form { display:grid; grid-template-columns:1.25fr 1fr 1.2fr .65fr auto; gap:10px; margin:34px 0; padding:14px; border:1px solid var(--line); border-radius:16px; background:var(--card); box-shadow:var(--shadow); }
.field label { display:block; margin:0 0 7px 3px; color:var(--muted); text-transform:uppercase; letter-spacing:.09em; font:750 9px/1 ui-monospace,SFMono-Regular,monospace; }
.field input,.field select { width:100%; height:43px; border:1px solid var(--line); border-radius:9px; outline:0; padding:0 11px; background:white; color:var(--ink); }
.field input,.field select { width:100%; height:43px; border:1px solid var(--line); border-radius:9px; outline:0; padding:0 11px; background:var(--card); color:var(--ink); }
.field input:focus,.field select:focus { border-color:var(--teal); box-shadow:0 0 0 3px rgba(187,213,218,.42); }
.account-form button { align-self:end; height:43px; }
.account-card { display:grid; grid-template-columns:1.35fr .65fr; gap:1px; background:var(--line); border:1px solid var(--line); border-radius:18px; overflow:hidden; margin-bottom:30px; }
@@ -151,6 +155,7 @@ templ Page(title string, active string, locale Locale, englishURL string, persia
<a href="/accounts">{ tr(locale, "accounts") }</a>
}
<span class="live"><span class="dot"></span> { tr(locale, "read_only") }</span>
<button class="theme-toggle" type="button" data-theme-toggle data-light-label={ tr(locale, "light_mode") } data-dark-label={ tr(locale, "dark_mode") } aria-label={ tr(locale, "dark_mode") } aria-pressed="false"><span data-theme-icon aria-hidden="true">☾</span></button>
<span class="language-switch" aria-label="Language">
if locale == LocaleEnglish {
<a class="active" href={ templ.URL(englishURL) } hx-boost="false" lang="en">EN</a>
File diff suppressed because one or more lines are too long