Skip to content

Commit ee6b743

Browse files
committed
feat: system-aware light/dark theme with header toggle
Follow prefers-color-scheme by default, persist light/dark/system in localStorage, and expose a sun/moon control in the header. FOUC-safe boot script applies the resolved theme before first paint.
1 parent dce8740 commit ee6b743

4 files changed

Lines changed: 228 additions & 35 deletions

File tree

app/webui/app.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,84 @@
33
"use strict";
44

55
const TOKEN_KEY = "vocaphone.token";
6+
const THEME_KEY = "vocaphone.theme";
67
const overlay = document.getElementById("token-overlay");
78
const tokenInput = document.getElementById("token-input");
89
const tokenError = document.getElementById("token-error");
910
const toast = document.getElementById("toast");
11+
const themeToggle = document.getElementById("theme-toggle");
1012

1113
const getToken = () => localStorage.getItem(TOKEN_KEY) || "";
1214

15+
// --------------------------------------------------------------- theme
16+
17+
function systemPrefersDark() {
18+
return window.matchMedia("(prefers-color-scheme: dark)").matches;
19+
}
20+
21+
function readThemePreference() {
22+
const pref = localStorage.getItem(THEME_KEY);
23+
if (pref === "light" || pref === "dark" || pref === "system") return pref;
24+
return "system";
25+
}
26+
27+
function resolveTheme(preference) {
28+
if (preference === "light" || preference === "dark") return preference;
29+
return systemPrefersDark() ? "dark" : "light";
30+
}
31+
32+
function themeLabel(preference, resolved) {
33+
if (preference === "system") {
34+
return `Theme: system (${resolved}). Click for light.`;
35+
}
36+
if (preference === "light") return "Theme: light. Click for dark.";
37+
return "Theme: dark. Click to follow system.";
38+
}
39+
40+
function applyTheme(preference) {
41+
const resolved = resolveTheme(preference);
42+
document.documentElement.setAttribute("data-theme", resolved);
43+
document.documentElement.setAttribute("data-theme-preference", preference);
44+
const meta = document.getElementById("meta-theme-color");
45+
if (meta) {
46+
meta.setAttribute("content", resolved === "dark" ? "#141614" : "#f7f6f3");
47+
}
48+
if (themeToggle) {
49+
themeToggle.dataset.preference = preference;
50+
const label = themeLabel(preference, resolved);
51+
themeToggle.setAttribute("aria-label", label);
52+
themeToggle.setAttribute("title", label);
53+
}
54+
}
55+
56+
function cycleThemePreference(current) {
57+
// system → light → dark → system. Default is system so first-time
58+
// operators match the OS; clicks then pin an explicit choice.
59+
if (current === "system") return "light";
60+
if (current === "light") return "dark";
61+
return "system";
62+
}
63+
64+
function initTheme() {
65+
applyTheme(readThemePreference());
66+
if (themeToggle) {
67+
themeToggle.addEventListener("click", () => {
68+
const next = cycleThemePreference(readThemePreference());
69+
localStorage.setItem(THEME_KEY, next);
70+
applyTheme(next);
71+
});
72+
}
73+
const media = window.matchMedia("(prefers-color-scheme: dark)");
74+
const onSystemChange = () => {
75+
if (readThemePreference() === "system") applyTheme("system");
76+
};
77+
if (typeof media.addEventListener === "function") {
78+
media.addEventListener("change", onSystemChange);
79+
} else if (typeof media.addListener === "function") {
80+
media.addListener(onSystemChange);
81+
}
82+
}
83+
1384
function showToast(message, isError = true) {
1485
toast.textContent = message;
1586
toast.classList.toggle("error", isError);
@@ -391,6 +462,8 @@
391462

392463
// ------------------------------------------------------------------ start
393464

465+
initTheme();
466+
394467
if (!getToken()) {
395468
showOverlay();
396469
}

app/webui/index.html

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
<title>VocaPhone gateway · private speech transcription</title>
77
<meta name="description" content="Local VocaPhone gateway: pair your phone, pick speech models, and run private on-device transcription without sending audio to the cloud." />
88
<meta name="robots" content="noindex, nofollow" />
9-
<meta name="theme-color" content="#f7f6f3" />
10-
<meta name="color-scheme" content="light" />
9+
<meta name="theme-color" content="#f7f6f3" id="meta-theme-color" />
10+
<meta name="color-scheme" content="light dark" />
1111
<meta property="og:title" content="VocaPhone gateway" />
1212
<meta property="og:description" content="Private transcription gateway for the VocaPhone phone apps. Pair, manage models, and test speech locally." />
1313
<meta property="og:type" content="website" />
@@ -19,6 +19,8 @@
1919
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
2020
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=Epilogue:wght@500;600;700&display=swap" />
2121
<link rel="stylesheet" href="/assets/styles.css" />
22+
<!-- External boot script (CSP script-src 'self') so theme applies before paint. -->
23+
<script src="/assets/theme-boot.js"></script>
2224
</head>
2325
<body>
2426
<a class="skip-link" href="#panel">Skip to content</a>
@@ -43,13 +45,27 @@ <h1>VocaPhone</h1>
4345
<span class="muted">Private transcription gateway</span>
4446
</div>
4547
</div>
46-
<button type="button" id="engine-pill" class="engine-status"
47-
data-open-tab="models"
48-
title="Open Models to change the speech engine"
49-
aria-label="Current speech model. Open Models."
50-
hx-get="/ui/partials/engine-pill" hx-trigger="load, every 5s" hx-swap="outerHTML">
51-
<span class="dot" aria-hidden="true"></span><span>connecting&hellip;</span>
52-
</button>
48+
<div class="header-actions">
49+
<button type="button" id="theme-toggle" class="theme-toggle" data-preference="system"
50+
aria-label="Theme: system. Click to switch to light." title="Theme: system">
51+
<svg class="theme-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor"
52+
stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
53+
<circle cx="12" cy="12" r="4" />
54+
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
55+
</svg>
56+
<svg class="theme-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
57+
stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
58+
<path d="M21 14.5A8.5 8.5 0 1 1 9.5 3a7 7 0 0 0 11.5 11.5z" />
59+
</svg>
60+
</button>
61+
<button type="button" id="engine-pill" class="engine-status"
62+
data-open-tab="models"
63+
title="Open Models to change the speech engine"
64+
aria-label="Current speech model. Open Models."
65+
hx-get="/ui/partials/engine-pill" hx-trigger="load, every 5s" hx-swap="outerHTML">
66+
<span class="dot" aria-hidden="true"></span><span>connecting&hellip;</span>
67+
</button>
68+
</div>
5369
</header>
5470

5571
<nav class="tabs" role="tablist" aria-label="Gateway sections">

app/webui/styles.css

Lines changed: 111 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,54 +3,85 @@
33
* Aligns with VocaHQ marketing design language ("quiet paper desk"):
44
* https://github.com/VocaHQ/vocahq/blob/main/web/DESIGN.md
55
*
6-
* Rules:
7-
* 1. Warm paper ground, near-black ink, teal for actions/status only.
8-
* 2. Hierarchy from type and hairlines, not nested cards or glows.
9-
* 3. Surfaces are earned (inputs, metric grids, QR plate); prose sits on paper.
6+
* Light is the brand default; dark is a graphite pair of the same teal accent.
7+
* html[data-theme="light"|"dark"] is set by a FOUC-safe boot script (system
8+
* preference unless the operator overrides with the header toggle).
109
*/
1110

1211
:root {
13-
color-scheme: light;
12+
/* Shared tokens (theme-independent) */
13+
--r: 8px;
14+
--r-lg: 16px;
15+
--r-pill: 999px;
16+
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
17+
--font-display: "Epilogue", system-ui, sans-serif;
18+
--font-body: "Atkinson Hyperlegible", system-ui, sans-serif;
19+
20+
/* One gutter for the header, the nav and the content, so their left edges line
21+
up at every width. It tracks `main`'s own max-width and padding. */
22+
/* 100%, not 100vw: 100vw includes the scrollbar, which would push the header a
23+
few pixels left of the content it is supposed to line up with. */
24+
--gutter: max(24px, calc((100% - 1048px) / 2 + 24px));
25+
}
1426

15-
/* VocaHQ DESIGN.md neutrals + accent */
27+
/* Light: VocaHQ DESIGN.md paper desk */
28+
html, html[data-theme="light"] {
29+
color-scheme: light;
1630
--bg: #f7f6f3;
1731
--surface: #ffffff;
1832
--raised: #efeeea;
1933
--sunken: #efeeea;
20-
2134
--line: #e2e0da;
2235
--line-soft: #eceae4;
2336
--line-strong: #cdcbc3;
24-
2537
--text: #1a1c1b;
2638
--muted: #5c6360;
2739
--faint: #8a918d;
28-
2940
--accent: #1f8f75;
3041
--accent-hover: #176b58;
3142
--accent-ink: #ffffff;
3243
--accent-line: #1f8f75;
3344
--accent-wash: #e6f4ef;
34-
3545
--warn: #9a6b16;
3646
--warn-wash: rgba(154, 107, 22, 0.1);
3747
--error: #b42318;
3848
--error-wash: rgba(180, 35, 24, 0.08);
39-
40-
--r: 8px;
41-
--r-lg: 16px;
42-
--r-pill: 999px;
43-
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
44-
--font-display: "Epilogue", system-ui, sans-serif;
45-
--font-body: "Atkinson Hyperlegible", system-ui, sans-serif;
4649
--shadow-soft: 0 10px 30px rgba(26, 28, 27, 0.08);
4750
--shadow-modal: 0 18px 48px rgba(26, 28, 27, 0.12);
48-
49-
/* One gutter for the header, the nav and the content, so their left edges line
50-
up at every width. It tracks `main`'s own max-width and padding. */
51-
/* 100%, not 100vw: 100vw includes the scrollbar, which would push the header a
52-
few pixels left of the content it is supposed to line up with. */
53-
--gutter: max(24px, calc((100% - 1048px) / 2 + 24px));
51+
--overlay-scrim: rgba(26, 28, 27, 0.36);
52+
--tabs-bg: rgba(247, 246, 243, 0.92);
53+
--theme-color: #f7f6f3;
54+
--select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1.75 6 6.25l5-4.5' fill='none' stroke='%235c6360' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
55+
}
56+
57+
/* Dark: graphite desk, same teal family (readable on near-black) */
58+
html[data-theme="dark"] {
59+
color-scheme: dark;
60+
--bg: #141614;
61+
--surface: #1b1c1b;
62+
--raised: #232522;
63+
--sunken: #101210;
64+
--line: #2e312e;
65+
--line-soft: #262924;
66+
--line-strong: #3d423e;
67+
--text: #e3e4e1;
68+
--muted: #a2aba5;
69+
--faint: #79827c;
70+
--accent: #77d0b2;
71+
--accent-hover: #8fdcc1;
72+
--accent-ink: #00382a;
73+
--accent-line: #2f6e5c;
74+
--accent-wash: rgba(119, 208, 178, 0.12);
75+
--warn: #e3b778;
76+
--warn-wash: rgba(227, 183, 120, 0.12);
77+
--error: #ffb4ab;
78+
--error-wash: rgba(255, 180, 171, 0.12);
79+
--shadow-soft: 0 12px 32px rgba(0, 0, 0, 0.35);
80+
--shadow-modal: 0 20px 50px rgba(0, 0, 0, 0.5);
81+
--overlay-scrim: rgba(10, 12, 10, 0.72);
82+
--tabs-bg: rgba(20, 22, 20, 0.92);
83+
--theme-color: #141614;
84+
--select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1.75 6 6.25l5-4.5' fill='none' stroke='%23a2aba5' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
5485
}
5586

5687
* { box-sizing: border-box; }
@@ -166,6 +197,60 @@ header {
166197
padding: 18px var(--gutter) 14px;
167198
}
168199

200+
.header-actions {
201+
display: flex;
202+
align-items: center;
203+
gap: 10px;
204+
min-width: 0;
205+
}
206+
207+
.theme-toggle {
208+
position: relative;
209+
flex: 0 0 auto;
210+
display: inline-flex;
211+
align-items: center;
212+
justify-content: center;
213+
width: 40px;
214+
height: 40px;
215+
margin: 0;
216+
padding: 0;
217+
border: 1px solid var(--line);
218+
border-radius: var(--r-pill);
219+
background: var(--surface);
220+
color: var(--muted);
221+
cursor: pointer;
222+
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
223+
}
224+
.theme-toggle:hover {
225+
border-color: var(--accent);
226+
color: var(--text);
227+
background: var(--accent-wash);
228+
}
229+
.theme-toggle:focus-visible {
230+
outline: 2px solid var(--accent);
231+
outline-offset: 2px;
232+
}
233+
.theme-toggle svg {
234+
display: block;
235+
width: 18px;
236+
height: 18px;
237+
}
238+
/* Show the icon for the action (moon in light → go dark; sun in dark → go light). */
239+
html[data-theme="light"] .theme-icon-sun,
240+
html[data-theme="dark"] .theme-icon-moon { display: none; }
241+
html[data-theme="light"] .theme-icon-moon,
242+
html[data-theme="dark"] .theme-icon-sun { display: block; }
243+
/* System preference: small badge on the control. */
244+
.theme-toggle[data-preference="system"] {
245+
box-shadow: inset 0 0 0 1px var(--accent-wash);
246+
}
247+
.theme-toggle[data-preference="system"]::after {
248+
content: "";
249+
position: absolute;
250+
/* decorative only; preference is in aria-label */
251+
display: none;
252+
}
253+
169254
.brand { display: flex; align-items: center; gap: 12px; }
170255
.logo { display: block; width: 28px; height: 28px; border-radius: 50%; }
171256
.brand h1 {
@@ -234,7 +319,7 @@ header {
234319
z-index: 20;
235320
padding: 0 var(--gutter);
236321
border-bottom: 1px solid var(--line);
237-
background: rgba(247, 246, 243, 0.92);
322+
background: var(--tabs-bg);
238323
backdrop-filter: saturate(140%) blur(10px);
239324
}
240325
.tab {
@@ -748,7 +833,7 @@ select {
748833
appearance: none;
749834
padding-right: 32px;
750835
background-color: var(--surface);
751-
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1.75 6 6.25l5-4.5' fill='none' stroke='%235c6360' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
836+
background-image: var(--select-chevron);
752837
background-repeat: no-repeat;
753838
background-position: right 12px center;
754839
cursor: pointer;
@@ -816,7 +901,7 @@ select {
816901
.overlay {
817902
position: fixed;
818903
inset: 0;
819-
background: rgba(26, 28, 27, 0.36);
904+
background: var(--overlay-scrim);
820905
backdrop-filter: blur(4px);
821906
display: flex;
822907
align-items: center;

app/webui/theme-boot.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/* FOUC-safe theme boot: runs before body paint. Preference is system unless
2+
localStorage.vocaphone.theme is light/dark/system. */
3+
(function () {
4+
"use strict";
5+
try {
6+
var key = "vocaphone.theme";
7+
var pref = localStorage.getItem(key);
8+
if (pref !== "light" && pref !== "dark" && pref !== "system") pref = "system";
9+
var systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
10+
var resolved = pref === "light" || pref === "dark" ? pref : systemDark ? "dark" : "light";
11+
document.documentElement.setAttribute("data-theme", resolved);
12+
document.documentElement.setAttribute("data-theme-preference", pref);
13+
var meta = document.getElementById("meta-theme-color");
14+
if (meta) meta.setAttribute("content", resolved === "dark" ? "#141614" : "#f7f6f3");
15+
} catch (e) {
16+
document.documentElement.setAttribute("data-theme", "light");
17+
document.documentElement.setAttribute("data-theme-preference", "system");
18+
}
19+
})();

0 commit comments

Comments
 (0)