diff --git a/blocks/footer.css b/blocks/footer.css index 3f21850..968818b 100644 --- a/blocks/footer.css +++ b/blocks/footer.css @@ -1,4 +1,4 @@ - /* ─── prose footer · v3 · {{variant}} ─────────────────────────────────── + /* ─── prose footer · v4 · {{variant}} ─────────────────────────────────── Generated from @robertblust/design — edit it there and run `npm run design`. Two forms, and the difference is not styling. companygraph.io and @@ -10,8 +10,12 @@ The row is mono because the row is data: a repository URL and a license. The credit leaves that mono deliberately, because a brand is not a record value. Both facts belong here rather than in sixteen page comments. A link - in the row is the family's link, from the `link` block, with nothing of its own. */ - footer{padding:0 0 2rem; display:flex; flex-wrap:wrap; gap:.9rem 2.6rem; align-items:center; + in the row is the family's link, from the `link` block, with nothing of its own. + + The row's floor adds the phone's bottom inset. A page that declares `viewport-fit=cover` + draws under Android's and iOS's bottom bars, and the last row would sit under them; + everywhere else the inset is 0 and the floor is the 2rem it always was. */ + footer{padding:0 0 calc(2rem + env(safe-area-inset-bottom)); display:flex; flex-wrap:wrap; gap:.9rem 2.6rem; align-items:center; font-family:"Plex Mono",monospace; font-size:.76rem; letter-spacing:.08em; color:var(--dim)} {{credit}} footer a[aria-current="page"]{color:var(--c-firm)} /* ─── end prose footer ──────────────────────────────────────────────────*/ diff --git a/blocks/theme.js b/blocks/theme.js index 09477ed..e0a1eb1 100644 --- a/blocks/theme.js +++ b/blocks/theme.js @@ -1,4 +1,4 @@ - /* ─── theme · v5 · {{variant}} ─────────────────────────────────────── + /* ─── theme · v6 · {{variant}} ─────────────────────────────────────── One theme across three domains, and where it is remembered. Generated from @robertblust/design — editing it here does nothing. @@ -66,4 +66,24 @@ if (d) d.setAttribute("aria-pressed", theme === "dark" ? "true" : "false"); } function setTheme(v){ theme = v; themeRemember(v); applyTheme(); } + + /* A page opened from a phone's home screen has Android paint its system bars from the page's + `theme-color`. A page declares that meta with dark's ground, the default, and this keeps it + on the ground of the theme the visitor actually sees. It watches `data-theme` rather than + hooking `applyTheme`, because a page may switch the theme with a copy of its own — blust.ch's + pages do — and the attribute is the one thing every switch sets. The color is read from the + tokens rather than restated here, so a re-picked ground moves the bars with it. A page + without the meta is left as it is. */ + function themeColor(){ + try { + var tc = document.querySelector('meta[name="theme-color"]'); + if (!tc) return; + var g = getComputedStyle(document.documentElement).getPropertyValue("--ground").trim(); + if (g) tc.setAttribute("content", g); + } catch (e) {} + } + themeColor(); + try { + new MutationObserver(themeColor).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); + } catch (e) {} /* ─── end theme ──────────────────────────────────────────────────────── */ diff --git a/docs/design-system/blocks.md b/docs/design-system/blocks.md index bb90d66..a87c2dd 100644 --- a/docs/design-system/blocks.md +++ b/docs/design-system/blocks.md @@ -66,7 +66,7 @@ The lines a prose page lifts out of its paragraphs, each drawn in a color role f ## Footer -The row at the foot of every prose page: the repository and the license, and on companygraph.io and guestgraph.io the credit to blust.ch. A site names its form in `design.config.json` as `"footer": "credit"` or `"plain"`. Contract: [blocks/footer.css](../../blocks/footer.css), and the credit's rules in [blocks/footer-credit.css](../../blocks/footer-credit.css). +The row at the foot of every prose page: the repository and the license, and on companygraph.io and guestgraph.io the credit to blust.ch. A site names its form in `design.config.json` as `"footer": "credit"` or `"plain"`. On a phone the row keeps clear of the system's bottom bar. Contract: [blocks/footer.css](../../blocks/footer.css), and the credit's rules in [blocks/footer-credit.css](../../blocks/footer-credit.css). ```text credit @@ -200,6 +200,6 @@ These blocks draw nothing of their own and have no picture. | Block | What it does | Contract | | --- | --- | --- | | Theme boot | Sets the theme before the first paint | [blocks/theme-boot.js](../../blocks/theme-boot.js) | -| Theme | Switches and remembers light and dark across the family's domains | [blocks/theme.js](../../blocks/theme.js) | +| Theme | Switches and remembers light and dark across the family's domains, and keeps a page's `theme-color` on the ground of the theme shown | [blocks/theme.js](../../blocks/theme.js) | | Language | Decides and remembers English or German across the family's domains | [blocks/lang.js](../../blocks/lang.js) | | Nav fit | Measures whether the header row fits | [blocks/nav-fit.js](../../blocks/nav-fit.js) | diff --git a/test/theme.test.mjs b/test/theme.test.mjs index f2fc5d2..0926766 100644 --- a/test/theme.test.mjs +++ b/test/theme.test.mjs @@ -920,3 +920,61 @@ test("--c-sum, the conclusion line, clears 3:1 against its own ground, in both t assert.ok(r >= 3, `${name}: --c-sum is ${r.toFixed(2)}:1 on --ground, needs 3`); } }); + +// The theme-color hook is run for real, as carryTheme is above: a regex over the source would +// pass a block that names `theme-color` and `--ground` in a comment while never setting the meta. +// The page switches the theme through `documentElement` alone, the way a page carrying its own +// copy of the switch does, so the hook is shown to follow the attribute, not `setTheme`. +function runThemeColor({ meta, grounds, start = null }) { + const js = blockFor("theme", "page"); + let dataTheme = start; + const observers = []; + const documentElement = { + getAttribute: (n) => (n === "data-theme" ? dataTheme : null), + setAttribute(n, v) { if (n === "data-theme") { dataTheme = v; observers.forEach((f) => f()); } }, + removeAttribute(n) { if (n === "data-theme") { dataTheme = null; observers.forEach((f) => f()); } }, + }; + const sandbox = { + console, URL, + localStorage: { getItem: () => null, setItem() {} }, + location: { href: "https://blust.ch/", origin: "https://blust.ch", search: "", pathname: "/", hash: "" }, + history: { replaceState() {} }, + document: { + addEventListener() {}, documentElement, getElementById: () => null, + querySelector: (sel) => (sel === 'meta[name="theme-color"]' ? meta : null), + }, + getComputedStyle: () => ({ getPropertyValue: (n) => (n === "--ground" ? grounds[dataTheme === "light" ? "light" : "dark"] : "") }), + MutationObserver: class { + constructor(f) { this.f = f; } + observe(target, opts) { if (target === documentElement && opts.attributeFilter.includes("data-theme")) observers.push(this.f); } + }, + }; + vm.createContext(sandbox); + vm.runInContext(js, sandbox); + return { documentElement }; +} + +test("theme-color follows data-theme, whichever switch sets it", () => { + // Android paints a home-screen page's system bars from `theme-color`; a light visitor whose + // bars stayed dark's ground would see a dark band under a light page. + const content = { value: "#0C0E13" }; + const meta = { setAttribute(n, v) { if (n === "content") content.value = v; } }; + const { documentElement } = runThemeColor({ meta, grounds: { dark: " #0C0E13", light: " #FAF9F5" } }); + documentElement.setAttribute("data-theme", "light"); + assert.equal(content.value, "#FAF9F5", "light did not move theme-color to light's ground"); + documentElement.removeAttribute("data-theme"); + assert.equal(content.value, "#0C0E13", "dark did not move theme-color back to dark's ground"); +}); + +test("theme-color takes the theme a page loads in", () => { + // `theme boot` has already set light before this block runs; the bars must not wait for a click. + const content = { value: "#0C0E13" }; + const meta = { setAttribute(n, v) { if (n === "content") content.value = v; } }; + runThemeColor({ meta, grounds: { dark: "#0C0E13", light: "#FAF9F5" }, start: "light" }); + assert.equal(content.value, "#FAF9F5"); +}); + +test("a page without a theme-color meta is left alone", () => { + const { documentElement } = runThemeColor({ meta: null, grounds: { dark: "#0C0E13", light: "#FAF9F5" } }); + assert.doesNotThrow(() => documentElement.setAttribute("data-theme", "light")); +}); diff --git a/versions.json b/versions.json index 36ff290..2dfa7a2 100644 --- a/versions.json +++ b/versions.json @@ -5,14 +5,14 @@ "title": "v2", "reset": "v3", "lang": "v5", - "footer": "v3", + "footer": "v4", "transport": "v11", "lockup": "v1", "fit": "v1", "rests": "v1", "runtime": "v10", "themeBoot": "v6", - "theme": "v5", + "theme": "v6", "navFit": "v3", "processes": "v1", "surfaces": "v2",