Add a header, a theme toggle and visible checkboxes

Three pieces of chrome that were missing or misleading.

A header with the bowl mark and the app name, on every page. The page title
bar below it is no longer sticky: on a phone a tall sticky header eats the
screen, and the bottom tab bar already handles navigation.

A theme toggle, remembered per device in localStorage, because a shared
instance with no accounts should let the kitchen phone and the laptop
disagree. Dark by default, and the default lives in the server-rendered
markup so it survives with JavaScript off and never flashes. The button shows
the theme that is on — moon while dark, sun while light — rather than the one
a click would bring, and its label names the state before the action. Both
icons ship on every page and CSS picks one, so the server never needs to know
what this device chose. Following the system was considered and dropped: a
third state costs a control harder to read than the choice is worth.

The checkbox chips no longer hide the native control behind opacity: 0. With
only a background colour to go on there was no way to tell "Tarjoillaan
lisukkeiden kanssa" on from off. Colour is not a state indicator; a checkbox
is. This covers the side, category and has_sides chips alike.

Smoke checks cover the parts that would regress silently: that dark is the
no-JS default, and that both theme icons are present for CSS to choose from.
This commit is contained in:
Esa Kataja
2026-09-05 19:05:41 +03:00
parent 0538d61ba6
commit 15495ec0c0
6 changed files with 189 additions and 29 deletions
+62 -12
View File
@@ -23,6 +23,10 @@
--tap: 48px; /* minimum touch target */
}
/* Dark is the default; theme.js swaps the attribute from localStorage. */
html[data-theme="light"] { color-scheme: only light; }
html[data-theme="dark"] { color-scheme: only dark; }
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
body {
@@ -39,13 +43,50 @@ button, input, select { font: inherit; }
:focus-visible { outline: 2.5px solid var(--accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
.brandbar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
color: var(--ink);
text-decoration: none;
font-size: 16px;
font-weight: 700;
letter-spacing: -0.03em;
}
.brand img { display: block; }
/* One button showing the theme that is on: moon while dark, sun while light.
Both icons ship in the markup and CSS picks one, so the server never needs
to know what this device chose. */
.themetoggle {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
margin-left: auto;
padding: 0;
background: var(--sunk);
border: 1px solid var(--line);
border-radius: 10px;
color: var(--ink);
cursor: pointer;
}
.themetoggle:hover { color: var(--accent); border-color: var(--accent); }
.themetoggle svg { display: block; }
html[data-theme="dark"] .themetoggle .i-sun { display: none; }
html[data-theme="light"] .themetoggle .i-moon { display: none; }
.appbar {
position: sticky;
top: 0;
z-index: 5;
background: var(--paper);
border-bottom: 1px solid var(--line);
padding: 14px 16px 12px;
padding: 2px 16px 12px;
}
.appbar h2 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -0.03em; }
.appbar .meta { margin: 2px 0 0; font-size: 12.5px; color: var(--muted); }
@@ -172,22 +213,32 @@ button, input, select { font: inherit; }
/* Sides step */
.q { margin: 14px 0 10px; font-size: 13.5px; color: var(--muted); }
.chips { display: flex; gap: 7px; flex-wrap: wrap; margin-bottom: 16px; }
/* The checkbox stays visible. Colour alone is not a state indicator: with the
native control hidden there was no way to tell a chip on from a chip off. */
.chip {
display: flex;
align-items: center;
min-height: 42px;
padding: 0 14px;
gap: 9px;
min-height: 44px;
padding: 0 15px 0 12px;
background: var(--sunk);
border: 1px solid var(--line);
border-radius: 999px;
border-radius: 10px;
font-size: 14.5px;
cursor: pointer;
}
.chip input { position: absolute; opacity: 0; pointer-events: none; }
.chip input[type="checkbox"] {
flex: none;
width: 19px;
height: 19px;
margin: 0;
accent-color: var(--accent);
cursor: pointer;
}
.chip:has(input:checked) {
background: var(--accent);
border-color: var(--accent);
color: var(--onacc);
box-shadow: inset 0 0 0 1px var(--accent);
font-weight: 600;
}
.chip:has(input:focus-visible) { outline: 2.5px solid var(--accent); outline-offset: 2px; }
@@ -394,8 +445,7 @@ button, input, select { font: inherit; }
padding: 0 12px;
font-size: 16px;
}
.chip.wide { width: 100%; margin-bottom: 14px; border-radius: 10px; gap: 8px; }
.chip .dot { margin-right: 6px; }
.chip.wide { width: 100%; margin-bottom: 14px; }
.formerr {
margin: 0 0 12px;
padding: 10px 12px;
+54
View File
@@ -0,0 +1,54 @@
// Per-device theme preference. The instance is shared and has no accounts, so
// this is localStorage rather than a server-side setting: the phone in the
// kitchen and the laptop are allowed to disagree.
//
// Loaded without defer, so a stored choice is applied before first paint and
// the theme never flashes. Dark is the default; the markup already carries
// data-theme="dark" so it holds without JavaScript too.
(function () {
var KEY = "foodster-teema";
var root = document.documentElement;
// The label names the state first and the action second, matching the
// icon, which shows the theme that is on rather than the one a click
// would bring.
var LABEL = {
dark: "Tumma teema. Vaihda vaaleaan.",
light: "Vaalea teema. Vaihda tummaan.",
};
function apply(theme) {
root.dataset.theme = theme === "light" ? "light" : "dark";
}
try {
apply(localStorage.getItem(KEY));
} catch (e) {
apply("dark"); // private mode, or storage disabled
}
document.addEventListener("DOMContentLoaded", function () {
var button = document.querySelector("[data-theme-toggle]");
if (!button) {
return;
}
function sync() {
var label = LABEL[root.dataset.theme];
button.setAttribute("aria-label", label);
button.setAttribute("title", label);
}
button.addEventListener("click", function () {
apply(root.dataset.theme === "dark" ? "light" : "dark");
try {
localStorage.setItem(KEY, root.dataset.theme);
} catch (e) {
/* the preference just will not persist */
}
sync();
});
sync();
});
})();
+49 -1
View File
@@ -71,7 +71,9 @@ func countFI(n int, one, many string) string {
templ page(title, current string) {
<!DOCTYPE html>
<html lang="fi">
// Dark is the default, set here so it holds even before theme.js runs and
// for anyone without JavaScript.
<html lang="fi" data-theme="dark">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
@@ -85,15 +87,61 @@ templ page(title, current string) {
would otherwise come back 401 and be ignored. -->
<link rel="manifest" href="/static/manifest.webmanifest" crossorigin="use-credentials"/>
<link rel="stylesheet" href="/static/app.css"/>
<!-- Not deferred: it applies the stored theme before first paint. -->
<script src="/static/theme.js"></script>
<script type="module" src="/static/datastar.js"></script>
</head>
<body>
@brandbar()
{ children... }
@tabbar(current)
</body>
</html>
}
templ brandbar() {
<header class="brandbar">
<a class="brand" href="/">
<img src="/static/favicon.svg" width="24" height="24" alt=""/>
<span>Foodster</span>
</a>
@themeSwitch()
</header>
}
// themeSwitch marks the active theme rather than labelling itself with the one
// a click would produce. aria-pressed is set by theme.js on load, because only
// the device knows what was chosen.
// themeSwitch shows the theme that is on right now — moon while dark, sun
// while light — and clicking swaps it. Both icons are in the markup and CSS
// picks one, so the server never has to know the device's choice.
// theme.js writes the label, which names the state and then the action.
templ themeSwitch() {
<button class="themetoggle" type="button" data-theme-toggle aria-label="Teema" title="Teema">
@iconSun()
@iconMoon()
</button>
}
templ iconSun() {
<svg class="i-sun" viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" focusable="false">
<circle cx="8" cy="8" r="3.1" fill="currentColor"></circle>
<path
d="M8 1.1v1.7M8 13.2v1.7M1.1 8h1.7M13.2 8h1.7M3.2 3.2l1.2 1.2M11.6 11.6l1.2 1.2M12.8 3.2l-1.2 1.2M4.4 11.6l-1.2 1.2"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
></path>
</svg>
}
templ iconMoon() {
<svg class="i-moon" viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" focusable="false">
<path d="M13.5 10.4A6 6 0 0 1 5.6 2.5 6.3 6.3 0 1 0 13.5 10.4z" fill="currentColor"></path>
</svg>
}
templ tabbar(current string) {
<nav class="tabbar">
@tab("/", "Kirjaa", current)