diff --git a/PRD.md b/PRD.md index daf108b..412917e 100644 --- a/PRD.md +++ b/PRD.md @@ -206,15 +206,18 @@ regenerates or swaps. shared and has no accounts, so this is a device preference in `localStorage`, never a server-side setting — the phone in the kitchen and the laptop must be able to disagree. -- Three states: light, dark, and follow the system. System is the default. -- The control **shows the state that is currently active**, not the state - clicking it would produce. A segmented control with the active option - marked, not a single button labelled with its opposite. A lightswitch does - not read "off" while the lights are on. -- Implementation is `color-scheme` on the root element: `light dark` for the - system-following default, `only light` or `only dark` when overridden. - Because the palette is built from `light-dark()` custom properties, no - other CSS changes. +- Two states only, **dark by default**. Following the system was considered + and dropped: a third state costs a control that is harder to read than the + choice is worth. +- One button, and its icon **is the theme that is currently on** — a moon + while dark, a sun while light. Not the state a click would produce. A + lightswitch does not read "off" while the lights are on. The accessible + label names the state first and the action second: "Tumma teema. Vaihda + vaaleaan." +- Implementation is `color-scheme` on the root element: `only light` or + `only dark`. The default is in the server-rendered markup, so it survives + having no JavaScript. Because the palette is built from `light-dark()` + custom properties, no other CSS changes. ## 8. Suggestion algorithm (stage 2) diff --git a/README.md b/README.md index 3067b0b..906b27a 100644 --- a/README.md +++ b/README.md @@ -24,15 +24,11 @@ Working: - **Ruoat** — add, edit and delete mains and sides, or import a whole bundle by paste or file upload. Deletes are soft, so old log entries keep showing the dish they used. +- **Light / dark**, remembered per device, dark by default. The button shows + the theme that is on — moon while dark, sun while light — not the one a + click would bring. Still to build: - -- **UI polish.** Checkbox chips hide the native control and signal state only - through background colour, so "Tarjoillaan lisukkeiden kanssa" gives no - clear read of on versus off. Show a real checkbox. Same applies to the - category and side chips. -- Light / dark theme switch, saved per device (PRD §7.4). -- A proper app header. - Stage 2: the seven-meal suggester, which starts once there is history to weight against. diff --git a/cmd/foodster/static/app.css b/cmd/foodster/static/app.css index dc34bd1..bd34057 100644 --- a/cmd/foodster/static/app.css +++ b/cmd/foodster/static/app.css @@ -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; diff --git a/cmd/foodster/static/theme.js b/cmd/foodster/static/theme.js new file mode 100644 index 0000000..982d18b --- /dev/null +++ b/cmd/foodster/static/theme.js @@ -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(); + }); +})(); diff --git a/cmd/foodster/views.templ b/cmd/foodster/views.templ index 454997f..f771635 100644 --- a/cmd/foodster/views.templ +++ b/cmd/foodster/views.templ @@ -71,7 +71,9 @@ func countFI(n int, one, many string) string { templ page(title, current string) { - + // Dark is the default, set here so it holds even before theme.js runs and + // for anyone without JavaScript. +
@@ -85,15 +87,61 @@ templ page(title, current string) { would otherwise come back 401 and be ignored. --> + + + @brandbar() { children... } @tabbar(current) } +templ brandbar() { +