Add dark theme toggle to web UI

CSS variables drive the palette; a ◐ button in the header flips
between light and dark, with localStorage persistence and a no-flash
inline init script. Respects prefers-color-scheme by default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 23:41:45 -04:00
parent 5f35195e72
commit fa20d2e516

View File

@@ -11,6 +11,35 @@
--muted: #6b6b6b; --muted: #6b6b6b;
--rule: #000; --rule: #000;
--soft: #f4f4f4; --soft: #f4f4f4;
--active: #222; /* pressed-button background */
--focus-muted: #888; /* placeholder on inverted input */
color-scheme: light;
}
/* Auto dark mode when the user hasn't explicitly chosen light. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--fg: #fff;
--bg: #000;
--muted: #8a8a8a;
--rule: #fff;
--soft: #0f0f0f;
--active: #d8d8d8;
--focus-muted: #777;
color-scheme: dark;
}
}
/* Explicit opt-in via the header toggle wins over OS preference. */
:root[data-theme="dark"] {
--fg: #fff;
--bg: #000;
--muted: #8a8a8a;
--rule: #fff;
--soft: #0f0f0f;
--active: #d8d8d8;
--focus-muted: #777;
color-scheme: dark;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { html, body {
@@ -20,7 +49,7 @@
font-size: 14px; line-height: 1.55; font-size: 14px; line-height: 1.55;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
::selection { background: #000; color: #fff; } ::selection { background: var(--fg); color: var(--bg); }
main { main {
max-width: 620px; max-width: 620px;
margin: 72px auto 96px; margin: 72px auto 96px;
@@ -76,7 +105,7 @@
content: ""; content: "";
display: inline-block; display: inline-block;
width: 10px; height: 1px; width: 10px; height: 1px;
background: #000; background: var(--fg);
} }
label { label {
@@ -101,10 +130,10 @@
transition: background 0.08s, color 0.08s; transition: background 0.08s, color 0.08s;
} }
input[type=text]:focus { input[type=text]:focus {
background: #000; color: #fff; background: var(--fg); color: var(--bg);
} }
input[type=text]::placeholder { color: var(--muted); } input[type=text]::placeholder { color: var(--muted); }
input[type=text]:focus::placeholder { color: #888; } input[type=text]:focus::placeholder { color: var(--focus-muted); }
button { button {
display: block; display: block;
@@ -122,8 +151,8 @@
border-radius: 0; border-radius: 0;
transition: background 0.08s, color 0.08s; transition: background 0.08s, color 0.08s;
} }
button:hover:not(:disabled) { background: #000; color: #fff; } button:hover:not(:disabled) { background: var(--fg); color: var(--bg); }
button:active:not(:disabled) { background: #222; } button:active:not(:disabled) { background: var(--active); }
button:disabled { button:disabled {
color: var(--muted); color: var(--muted);
cursor: not-allowed; cursor: not-allowed;
@@ -154,7 +183,7 @@
white-space: pre-wrap; white-space: pre-wrap;
} }
#log .line::before { content: "> "; color: var(--fg); } #log .line::before { content: "> "; color: var(--fg); }
#log .line.err { color: #000; font-weight: 600; } #log .line.err { color: var(--fg); font-weight: 600; }
#log .line.err::before { content: "! "; } #log .line.err::before { content: "! "; }
footer { footer {
@@ -168,7 +197,26 @@
justify-content: space-between; justify-content: space-between;
} }
footer a { color: var(--muted); text-decoration: none; border-bottom: 1px dotted var(--muted); } footer a { color: var(--muted); text-decoration: none; border-bottom: 1px dotted var(--muted); }
footer a:hover { color: #000; border-bottom-color: #000; } footer a:hover { color: var(--fg); border-bottom-color: var(--fg); }
/* Theme toggle — overrides the global button rule via class specificity. */
.theme-toggle {
margin-left: auto;
width: auto;
padding: 3px 9px;
font-size: 13px;
line-height: 1;
font-weight: 400;
letter-spacing: 0;
text-transform: none;
border: 1px solid var(--rule);
background: var(--bg);
color: var(--fg);
cursor: pointer;
transition: background 0.08s, color 0.08s;
}
.theme-toggle:hover { background: var(--fg); color: var(--bg); }
.theme-toggle:focus-visible { outline: 1px solid var(--fg); outline-offset: 2px; }
.diag { .diag {
border: 1px solid var(--rule); border: 1px solid var(--rule);
@@ -187,11 +235,23 @@
.diag .kv { display: grid; grid-template-columns: 140px 1fr; gap: 4px 12px; } .diag .kv { display: grid; grid-template-columns: 140px 1fr; gap: 4px 12px; }
.diag .k { color: var(--muted); text-transform: uppercase; letter-spacing: 0.1em; font-size: 10px; } .diag .k { color: var(--muted); text-transform: uppercase; letter-spacing: 0.1em; font-size: 10px; }
.diag .v { word-break: break-all; } .diag .v { word-break: break-all; }
.diag .v.ok { color: #000; } .diag .v.ok { color: var(--fg); }
.diag .v.bad { color: #000; font-weight: 700; } .diag .v.bad { color: var(--fg); font-weight: 700; }
.diag .v.bad::before { content: "✗ "; } .diag .v.bad::before { content: "✗ "; }
.diag .v.ok::before { content: "✓ "; } .diag .v.ok::before { content: "✓ "; }
</style> </style>
<script>
// No-flash theme init: runs synchronously before first paint so a
// user who last chose "dark" doesn't see a white flash on load.
(function () {
try {
var t = localStorage.getItem("nostr-passkey:theme");
if (t === "dark" || t === "light") {
document.documentElement.setAttribute("data-theme", t);
}
} catch (_) { /* localStorage blocked — fall back to OS preference */ }
})();
</script>
</head> </head>
<body> <body>
<main> <main>
@@ -199,6 +259,7 @@
<div class="title"> <div class="title">
<h1>nostr-passkey</h1> <h1>nostr-passkey</h1>
<span class="ver">v0.1 · prototype</span> <span class="ver">v0.1 · prototype</span>
<button id="btn-theme" class="theme-toggle" type="button" aria-label="Toggle theme" title="Toggle theme"></button>
</div> </div>
<div class="sub"><b>passkey</b><b>prf</b><b>hkdf</b><b>nostr key</b></div> <div class="sub"><b>passkey</b><b>prf</b><b>hkdf</b><b>nostr key</b></div>
</header> </header>
@@ -531,6 +592,18 @@ async function doDerive() {
document.getElementById("btn-register").addEventListener("click", doRegister); document.getElementById("btn-register").addEventListener("click", doRegister);
document.getElementById("btn-derive").addEventListener("click", doDerive); document.getElementById("btn-derive").addEventListener("click", doDerive);
// ---------- theme toggle ----------
function currentTheme() {
return document.documentElement.getAttribute("data-theme")
|| (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
}
function toggleTheme() {
const next = currentTheme() === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
try { localStorage.setItem("nostr-passkey:theme", next); } catch (_) {}
}
document.getElementById("btn-theme").addEventListener("click", toggleTheme);
</script> </script>
</body> </body>
</html> </html>