From 7c8cfef60e998c41797ba68c28d0dc4489bb68df Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 7 Aug 2026 17:19:42 -0400 Subject: [PATCH] feat(web): give every visitor the theme control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was a row in the account menu, so the only people who could choose a colour scheme were the ones who had signed in — and the setting is about this browser, not about an account. A reader of the blog never signs in at all. It is its own control on the bar now: an icon that opens a panel with the same three choices, mounted by the layout beside the account's and never waiting on /api/session. The panel is quieter than the account's because this is how the page is drawn, not who you are. --- TODO.md | 12 ++- web/scripts/theme.test.mjs | 27 +++++++ web/src/account.ts | 4 - web/src/layouts/Base.astro | 8 ++ web/src/styles.css | 162 +++++++++++++++++++++++-------------- web/src/theme.ts | 99 ++++++++++++++++++++--- 6 files changed, 231 insertions(+), 81 deletions(-) diff --git a/TODO.md b/TODO.md index 1c2a03c..08d7266 100644 --- a/TODO.md +++ b/TODO.md @@ -208,16 +208,20 @@ rediscovering them: Until then `/blog` is pages and nothing else. - [ ] **The masthead's account menu sends you to Home, because there is no profile page.** Signed in, the masthead shows the account's avatar and a - menu — the handle, Your account, Your matches, Your camo, the theme - control, Sign out — and the first three are the existing `/#`, - `/#matches` and `/#camo`. That is also the only place on the site holding - a setting of any kind, which is why the theme control is in it. Home's + menu — the handle, Your account, Your matches, Your camo, Sign out — and + the first three are the existing `/#`, `/#matches` and `/#camo`. Home's pilot card (`web/src/screens/pilot.ts`) is the closest thing to a profile page and is where the gap now shows: it counts a player's camo and their matches, and its Wins and Kills tiles are placeholders drawn as an em dash, because nothing records a match result yet. M3 is what fills them in. The control also means every page now asks `/api/session` once, the blog's included. + + The theme control is no longer in that menu. It was, on the grounds that + the menu was the only place on the site holding a setting of any kind, + and that made a setting about this browser reachable only by people who + had signed in. It is its own control on the bar now, mounted by the + layout beside the account's and waiting on nothing. - [ ] **Matches is behind sign-in, and its screen is a first pass.** The destination is marked `signedIn` in `web/src/destinations.ts`, which hides the masthead link and — through `isAllowed`, which the router calls diff --git a/web/scripts/theme.test.mjs b/web/scripts/theme.test.mjs index cf315ef..ad93648 100644 --- a/web/scripts/theme.test.mjs +++ b/web/scripts/theme.test.mjs @@ -22,6 +22,7 @@ const read = (path) => readFile(join(web, path), "utf8"); const theme = await read("src/theme.ts"); const layout = await read("src/layouts/Base.astro"); const css = await read("src/styles.css"); +const account = await read("src/account.ts"); const key = theme.match(/STORE_KEY = "([^"]+)"/)?.[1]; assert.ok(key, "theme.ts no longer declares STORE_KEY as a literal"); @@ -65,6 +66,32 @@ test("System is the absence of the attribute, not a third value", () => { ); }); +test("the layout leaves the box theme.ts fills", () => { + const slot = theme.match(/const SLOT = "#([^"]+)"/)?.[1]; + assert.ok(slot, "theme.ts no longer declares SLOT as a literal"); + assert.ok( + layout.includes(`id="${slot}"`), + `Base.astro has no #${slot} for the control to mount into`, + ); + assert.match( + layout, + /import \{ mountTheme \}[\s\S]*mountTheme\(\)/, + "the layout does not mount the theme control", + ); +}); + +test("the control does not belong to the account menu", () => { + // It used to, which meant the only people who could change the colour + // scheme were the ones who had signed in. Nothing errors if it moves back: + // the control keeps working for whoever built it, and quietly stops + // existing for everyone else — including every reader of the blog. + assert.doesNotMatch( + account, + /from "\.\/theme"/, + "the theme control is behind the session again", + ); +}); + test("no colour is chosen by a media query the toggle cannot override", () => { // A media query sees the browser's preference and not the player's choice, // so any token still set from one keeps its value on a page the player diff --git a/web/src/account.ts b/web/src/account.ts index ca2db7a..6aac2f8 100644 --- a/web/src/account.ts +++ b/web/src/account.ts @@ -20,7 +20,6 @@ import { el } from "./dom"; import { attachMenu } from "./menu"; import { nav } from "./nav"; import { accountName, avatarUrl, monogram } from "./profile"; -import { themeControl } from "./theme"; /** The empty box the layout leaves in the masthead. */ const SLOT = "#account"; @@ -114,9 +113,6 @@ function signedIn(session: Session): HTMLElement { link("/#", "Your account"), link("/#matches", "Your matches"), link("/#camo", "Your camo"), - // Not about the account — about this browser. It sits here because this - // menu is the only place on the site that holds settings at all. - themeControl(), signOutButton(status), status, ]); diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro index d7aa9ef..6381aba 100644 --- a/web/src/layouts/Base.astro +++ b/web/src/layouts/Base.astro @@ -160,6 +160,12 @@ const cardSize = image === DEFAULT_CARD ? { width: 1200, height: 630 } : null; )) } + { + /* Filled in by theme.ts. Nothing here waits on the session: the + colour scheme is a setting about this browser, and a reader who + never signs in has the same say in it as a player. */ + } +
{ /* Filled in by account.ts once it knows whether anyone is signed in. Empty rather than a Login button the layout guessed at: a @@ -176,7 +182,9 @@ const cardSize = image === DEFAULT_CARD ? { width: 1200, height: 630 } : null; } diff --git a/web/src/styles.css b/web/src/styles.css index 5b15e51..abdb7ed 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1,9 +1,9 @@ /* One stylesheet. Dark by default, because the game is. */ :root { - /* Dark unless the browser asks for light, and unless the account menu's - theme control has been used — that writes data-theme on this element and - the two rules below narrow color-scheme to one value. + /* Dark unless the browser asks for light, and unless the masthead's theme + control has been used — that writes data-theme on this element and the + two rules below narrow color-scheme to one value. Every colour that depends on the scheme is a light-dark() pair here rather than a second block of tokens under a media query. A media query @@ -160,9 +160,9 @@ body:has(.hero) { padding: 0 1.25rem; } -/* The nav and the account control travel together at the right-hand end, so - the account sits after the last destination rather than the nav being - pushed to the middle of the bar between two things. */ +/* The nav, the theme control and the account travel together at the right-hand + end, so they sit after the last destination rather than the nav being pushed + to the middle of the bar between two things. */ .masthead-end { display: flex; align-items: stretch; @@ -334,7 +334,8 @@ body:has(.hero) { /* Sized to the nav rather than to the page: the same uppercase label, the same full-height hit target, and none of the global button's fill — this is one more thing on the bar, not a call to action sitting on it. */ -.account-button { +.account-button, +.theme-button { display: flex; align-self: stretch; align-items: center; @@ -350,11 +351,13 @@ body:has(.hero) { text-transform: uppercase; } -.account-button:hover { +.account-button:hover, +.theme-button:hover { color: var(--ink); } -.account-button:focus-visible { +.account-button:focus-visible, +.theme-button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 3px; @@ -402,7 +405,11 @@ body:has(.hero) { letter-spacing: 0; } -.account-menu { +/* Both panels that hang off the bar. Same surface, same shadow, same edge — + they are two of one thing, and the account's is the one that sets the + width. */ +.account-menu, +.theme-menu { position: absolute; top: 100%; right: 0; @@ -420,7 +427,8 @@ body:has(.hero) { box-shadow: 0 12px 28px rgb(4 8 14 / 0.45); } -.account-menu[hidden] { +.account-menu[hidden], +.theme-menu[hidden] { display: none; } @@ -456,19 +464,88 @@ body:has(.hero) { background: color-mix(in srgb, var(--ink) 8%, transparent); } -/* The theme row. Not an .account-item: it is a setting with three states - rather than somewhere to go, so it is laid out as a label above the choices - instead of pretending to be another link. */ -.account-theme { +.account-signout { + margin-top: 0.15rem; + border-top: 1px solid var(--line); + border-radius: 0 0 4px 4px; + color: var(--muted); +} + +.account-menu .error { + margin: 0.25rem 0 0; + padding: 0 0.5rem; + font-size: 0.75rem; +} + +/* Narrow: the avatar is the control. A handle beside it costs more room than + the nav has, and the menu still names the account. */ +@media (max-width: 34rem) { + .account-handle { + display: none; + } +} + +@media (max-width: 30rem) { + .masthead-end { + gap: 0.25rem; + } + + .account { + min-width: 0; + } + + .account-button, + .theme-button { + padding: 0 0.45rem; + font-size: 0.7rem; + letter-spacing: 0.05em; + } +} + +/* --- the theme control --------------------------------------------------- + Its neighbour on the bar, and deliberately quieter: an icon with no label, + because the account is who you are and this is only how the page is drawn. + It is on every page, the blog's included, and it never waits on a session — + which is the whole reason it is no longer a row in the account menu. */ +.theme { + display: flex; + align-items: stretch; +} + +.theme-control { + position: relative; + display: flex; + align-items: stretch; +} + +.theme-button { + /* No text to space out, so the label's tracking would only widen the box. */ + padding: 0 0.5rem; + letter-spacing: 0; +} + +.theme-icon { + display: block; + width: 1.05rem; + height: 1.05rem; +} + +/* Narrower than the account's: one label and three short words is all that is + in it, and matching a menu holding a handle would leave it half empty. */ +.theme-menu { + min-width: 10.5rem; +} + +/* A label above the choices rather than beside them: "System" on its own says + nothing about what it sets, and the group needs the name either way. */ +.theme-rows { display: flex; flex-direction: column; gap: 0.3rem; - margin-top: 0.15rem; - padding: 0.4rem 0.5rem 0.15rem; - border-top: 1px solid var(--line); + padding: 0.35rem 0.5rem 0.4rem; } -.account-theme-label { +.theme-label { color: var(--muted); font-size: 0.72rem; font-weight: 600; @@ -478,7 +555,7 @@ body:has(.hero) { /* One segmented control, so the three buttons read as one choice. The gap is drawn by the container's own background showing through. */ -.account-theme-choices { +.theme-choices { display: flex; gap: 1px; overflow: hidden; @@ -487,7 +564,7 @@ body:has(.hero) { background: var(--line); } -.account-theme-choice { +.theme-choice { flex: 1; padding: 0.3rem 0.25rem; border: 0; @@ -499,59 +576,22 @@ body:has(.hero) { text-align: center; } -.account-theme-choice:hover { +.theme-choice:hover { color: var(--ink); background: color-mix(in srgb, var(--ink) 8%, var(--panel)); } -.account-theme-choice[aria-pressed="true"] { +.theme-choice[aria-pressed="true"] { background: color-mix(in srgb, var(--accent) 18%, var(--panel)); color: var(--ink); font-weight: 600; } -.account-theme-choice:focus-visible { +.theme-choice:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } -.account-signout { - margin-top: 0.15rem; - border-top: 1px solid var(--line); - border-radius: 0 0 4px 4px; - color: var(--muted); -} - -.account-menu .error { - margin: 0.25rem 0 0; - padding: 0 0.5rem; - font-size: 0.75rem; -} - -/* Narrow: the avatar is the control. A handle beside it costs more room than - the nav has, and the menu still names the account. */ -@media (max-width: 34rem) { - .account-handle { - display: none; - } -} - -@media (max-width: 30rem) { - .masthead-end { - gap: 0.25rem; - } - - .account { - min-width: 0; - } - - .account-button { - padding: 0 0.45rem; - font-size: 0.7rem; - letter-spacing: 0.05em; - } -} - /* --- page heads ---------------------------------------------------------- For the screens that have something to say above their content: the sign-in pitch, and the ones that have to explain what went wrong. A screen whose diff --git a/web/src/theme.ts b/web/src/theme.ts index c456091..076e32e 100644 --- a/web/src/theme.ts +++ b/web/src/theme.ts @@ -1,6 +1,12 @@ /** - * Which colour scheme the site is drawn in, and the control in the account - * menu that changes it. + * Which colour scheme the site is drawn in, and the masthead control that + * changes it. + * + * The control used to be a row in the account menu, which meant the only + * people who could reach it were the ones who had signed in. Nothing about a + * colour scheme is about an account — a reader of the blog has as much of an + * opinion as a player does — so it is its own control on the bar now, mounted + * by the layout on every page and never waiting on /api/session. * * There are three settings, not two. The stylesheet already followed the * browser's own preference, and a two-way toggle would have taken that away @@ -20,9 +26,13 @@ */ import { el } from "./dom"; +import { attachMenu } from "./menu"; export type Theme = "system" | "light" | "dark"; +/** The empty box the layout leaves in the masthead, beside the account's. */ +const SLOT = "#theme"; + /** Also spelled out in Base.astro's inline script. Change both. */ export const STORE_KEY = "lance.blue:theme"; @@ -55,19 +65,51 @@ export function applyTheme(theme: Theme): void { else root.dataset.theme = theme; } +/** Called by the layout's script, on every page. */ +export function mountTheme(): void { + const slot = document.querySelector(SLOT); + if (!slot) return; + slot.replaceChildren(themeMenu()); +} + /** - * The row in the account menu: a label and three buttons, one of them - * pressed. + * The control on the bar: a button that opens a panel holding the choices. * - * Buttons rather than a , so the whole thing is one click deep once + * the panel is open. They are a group with a name of its own because "System" + * on its own says nothing about what it sets. + */ +function themeControl(): HTMLElement { const buttons = CHOICES.map(({ theme, label }) => { const button = el("button", { type: "button", - className: "account-theme-choice", + className: "theme-choice", textContent: label, }); button.dataset.theme = theme; @@ -89,16 +131,49 @@ export function themeControl(): HTMLElement { }; mark(); - const group = el("div", { className: "account-theme-choices" }, buttons); + const group = el("div", { className: "theme-choices" }, buttons); group.setAttribute("role", "group"); group.setAttribute("aria-label", "Theme"); - return el("div", { className: "account-theme" }, [ - el("span", { className: "account-theme-label", textContent: "Theme" }), + return el("div", { className: "theme-rows" }, [ + el("span", { className: "theme-label", textContent: "Theme" }), group, ]); } +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** + * A circle with one half filled — the same glyph whichever scheme is on. + * + * Deliberately not a sun or a moon: with System in the list there are three + * answers and no icon says the third one, and an icon that changed would be + * claiming to report the setting rather than to open it. What the setting is + * is written inside the panel, where it is pressed. + */ +function icon(): SVGSVGElement { + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("class", "theme-icon"); + svg.setAttribute("viewBox", "0 0 16 16"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + + const ring = document.createElementNS(SVG_NS, "circle"); + ring.setAttribute("cx", "8"); + ring.setAttribute("cy", "8"); + ring.setAttribute("r", "6.25"); + ring.setAttribute("fill", "none"); + ring.setAttribute("stroke", "currentColor"); + ring.setAttribute("stroke-width", "1.5"); + + const half = document.createElementNS(SVG_NS, "path"); + half.setAttribute("d", "M8 1.75a6.25 6.25 0 0 0 0 12.5z"); + half.setAttribute("fill", "currentColor"); + + svg.append(ring, half); + return svg; +} + /** localStorage throws in private modes and when full; never let that matter. */ function read(): string | null { try { -- 2.51.2