Skip to content

Commit 6113040

Browse files
TMHSDigitalclaude
andcommitted
Theme toggle, web app manifest and an offline copy of the catalog (#111)
- theme.js, a blocking script in every page head (the CSP allows no inline script), applies system / light / dark before first paint from localStorage; the header button cycles the three. styles.css applies the dark tokens for the system scheme unless light is chosen, and whenever dark is - manifest.webmanifest (relative URLs, SVG icon, standalone) - sw.js (lib/offline.mjs): network first for every same-origin GET, so an online reader always gets the current build; the homepage, its modules, styles and fonts are precached at install; the cache is named by the build time and older ones are dropped Checked in a browser: the toggle cycles and persists; with the server stopped after one visit, the homepage loads and ?q=pixel returns 60. Not run: Lighthouse's installability audit (the icon is SVG only). Closes #111 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpkeXQPaUhxjgqdqnk6BrJ
1 parent ee97243 commit 6113040

6 files changed

Lines changed: 215 additions & 6 deletions

File tree

‎site/build.mjs‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { execFileSync } from "node:child_process";
1414
import fs from "node:fs";
1515
import path from "node:path";
1616
import { fileURLToPath } from "node:url";
17-
import { BROWSER_MODULES, isListed, esc, latestAllowedDate, OG_CARD_NAME, opensearchXml, siteFooterHtml, siteHeaderHtml } from "./lib/shared.mjs";
17+
import { BROWSER_MODULES, FONT_FILES, PAGE_MODULES, isListed, esc, latestAllowedDate, OG_CARD_NAME, opensearchXml, siteFooterHtml, siteHeaderHtml } from "./lib/shared.mjs";
1818
import { bodySearchText } from "./lib/search.mjs";
1919
import { filmstripHtml, groupedEntriesHtml } from "./lib/cards.mjs";
2020
import { guidePageHtml } from "./lib/guide-page.mjs";
@@ -37,6 +37,7 @@ import { stackPageHtml } from "./lib/stack-page.mjs";
3737
import { freshnessLineHtml, freshnessPageHtml, freshnessStats, newEntriesLineHtml } from "./lib/freshness.mjs";
3838
import { licenceTerms, listStackFiles, owes, parseStack, pickPath } from "./lib/stacks.mjs";
3939
import { createDistWriter } from "./lib/dist-writer.mjs";
40+
import { serviceWorkerJs, webManifest } from "./lib/offline.mjs";
4041
import { ATTRIBUTION_RANK, categoryChipsHtml, guideRowsHtml, headMetaHtml, licenseFamily, notFoundHtml, robotsTxt, stackRowsHtml, starterRowsHtml } from "./lib/home-page.mjs";
4142

4243
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -563,6 +564,12 @@ function main() {
563564
const modified = lastCommitDates();
564565
writeOut("sitemap.xml", sitemapXml({ site: config.site, entries: visible, stacks, stamp, modified, categories: categoriesInSitemap, guides: config.guides }));
565566
writeOut("robots.txt", robotsTxt(config.site));
567+
// Installable, and the catalog works offline after one visit (#111).
568+
writeOut("manifest.webmanifest", `${JSON.stringify(webManifest(config.site), null, 2)}\n`);
569+
writeOut("sw.js", serviceWorkerJs({
570+
version: payload.generatedAt,
571+
precache: [...new Set(["./", "data.js", "styles.css", "theme.js", "favicon.svg", ...PAGE_MODULES.home, ...FONT_FILES])],
572+
}));
566573
writeOut("opensearch.xml", opensearchXml(config.site));
567574
writeOut("404.html", notFoundHtml(config.site));
568575
writeOut(path.join("freshness", "index.html"), freshnessPageHtml({ stats: fresh, site: config.site, stamp, total: stats.listed, hasCard }));

‎site/lib/lib.test.mjs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2503,6 +2503,25 @@ await section("homepage helpers out of build.mjs (#85)", async () => {
25032503
eq("pickPath: query and fragment dropped like the resolver", pickPath("stacks/s.md", "../catalog/2d/a.md?x=1#y"), "catalog/2d/a.md");
25042504
});
25052505

2506+
/* theme, manifest and offline worker (#111) -------------------------------- */
2507+
await section("theme, manifest and offline worker (#111)", async () => {
2508+
const { webManifest, serviceWorkerJs } = await import("./offline.mjs");
2509+
const { headAssetsHtml, siteHeaderHtml } = await import("./shared.mjs");
2510+
const m = webManifest({ title: "Site", tagline: "Tag" });
2511+
eq("manifest: relative start and scope", `${m.start_url}|${m.scope}`, "./|./");
2512+
eq("manifest: standalone with an icon", `${m.display}|${m.icons[0].src}`, "standalone|favicon.svg");
2513+
const sw = serviceWorkerJs({ version: "2026-11-07T00:00:00Z", precache: ["./", "data.js"] });
2514+
has("worker: cache named by the build", sw, 'const CACHE = "fgda-2026-11-07T00:00:00Z";');
2515+
has("worker: precache list", sw, 'const PRECACHE = ["./","data.js"];');
2516+
has("worker: network first", sw, "fetch(req)");
2517+
has("worker: old caches dropped", sw, "caches.delete(k)");
2518+
has("worker: same origin only", sw, "self.location.origin");
2519+
const head = headAssetsHtml("../../").join("\n");
2520+
has("head: theme script, blocking, from the root", head, '<script src="../../theme.js"></script>');
2521+
has("head: manifest link", head, '<link rel="manifest" href="../../manifest.webmanifest" />');
2522+
has("header: theme toggle, hidden until theme.js runs", siteHeaderHtml({ root: "", repo: "https://g.test/o/r" }), 'id="theme-toggle" hidden');
2523+
});
2524+
25062525
/* report (keep last) ------------------------------------------------------ */
25072526
if (failures.length) {
25082527
console.error(`lib.test failed (${failures.length}):`);

‎site/lib/offline.mjs‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* The web app manifest and the offline service worker (#111). Pure: the
3+
* build writes both into dist. The worker is network-first for every
4+
* same-origin GET, so an online reader always gets the current build; what
5+
* it fetched is kept, and offline it answers from that copy. The homepage,
6+
* its scripts, styles and fonts are fetched at install, so search and the
7+
* shortlist work offline after one visit. The cache name carries the build
8+
* time, and older caches are deleted on activate.
9+
*/
10+
11+
/**
12+
* manifest.webmanifest for the site. Relative URLs resolve against the
13+
* manifest, which sits at the site root, so the same file works on Pages
14+
* and on a local server.
15+
*/
16+
export function webManifest(site) {
17+
return {
18+
name: site.title,
19+
short_name: "Free Game Dev Assets",
20+
description: site.tagline,
21+
start_url: "./",
22+
scope: "./",
23+
display: "standalone",
24+
background_color: "#eef1f4",
25+
theme_color: "#1a4d3e",
26+
icons: [{ src: "favicon.svg", sizes: "any", type: "image/svg+xml", purpose: "any maskable" }],
27+
};
28+
}
29+
30+
/** sw.js: `version` names the cache, `precache` lists site-relative files. */
31+
export function serviceWorkerJs({ version, precache }) {
32+
return `// Generated by site/build.mjs (lib/offline.mjs). Network first; the cache is the offline copy.
33+
const CACHE = ${JSON.stringify(`fgda-${version}`)};
34+
const PRECACHE = ${JSON.stringify(precache)};
35+
36+
self.addEventListener("install", (event) => {
37+
event.waitUntil(
38+
caches.open(CACHE).then((cache) => cache.addAll(PRECACHE.map((p) => new URL(p, self.registration.scope)))).then(() => self.skipWaiting())
39+
);
40+
});
41+
42+
self.addEventListener("activate", (event) => {
43+
event.waitUntil(
44+
caches.keys()
45+
.then((keys) => Promise.all(keys.filter((k) => k.startsWith("fgda-") && k !== CACHE).map((k) => caches.delete(k))))
46+
.then(() => self.clients.claim())
47+
);
48+
});
49+
50+
self.addEventListener("fetch", (event) => {
51+
const req = event.request;
52+
if (req.method !== "GET" || new URL(req.url).origin !== self.location.origin) return;
53+
event.respondWith(
54+
fetch(req)
55+
.then((res) => {
56+
if (res.ok && res.type === "basic") {
57+
const copy = res.clone();
58+
caches.open(CACHE).then((cache) => cache.put(req, copy));
59+
}
60+
return res;
61+
})
62+
.catch(async () => {
63+
const hit = await caches.match(req, { ignoreSearch: req.mode === "navigate" });
64+
if (hit) return hit;
65+
// An uncached page offline: the catalog home, which can still search.
66+
if (req.mode === "navigate") return caches.match(new URL("./", self.registration.scope));
67+
return Response.error();
68+
})
69+
);
70+
});
71+
`;
72+
}

‎site/lib/shared.mjs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ export function siteHeaderHtml({ root, repo }) {
250250
<a href="${root}#starters">Starters</a>
251251
<a href="${root}#guides">Guides</a>
252252
<a href="${esc(repo)}" rel="noopener noreferrer">GitHub</a>
253+
<button type="button" class="theme-toggle" id="theme-toggle" hidden>Theme: system</button>
253254
<span class="nav-sponsor">${sponsorButtonHtml()}</span>
254255
</nav>
255256
</header>`;
@@ -362,6 +363,9 @@ export function headAssetsHtml(root = "./") {
362363
`<link rel="icon" href="${esc(root)}favicon.svg" type="image/svg+xml" />`,
363364
...FONT_FILES.map((f) => `<link rel="preload" href="${esc(root)}${f}" as="font" type="font/woff2" crossorigin />`),
364365
`<link rel="stylesheet" href="${esc(root)}styles.css" />`,
366+
`<link rel="manifest" href="${esc(root)}manifest.webmanifest" />`,
367+
// Blocking on purpose: it sets the chosen theme before the first paint (#111).
368+
`<script src="${esc(root)}theme.js"></script>`,
365369
];
366370
}
367371

‎site/public/styles.css‎

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,47 @@
6060
color-scheme: light dark;
6161
}
6262

63-
/* The catalog is read at night as often as not. Same tokens, restated. */
63+
/* The catalog is read at night as often as not. Same tokens, restated: for
64+
the system dark scheme unless the reader chose light, and whenever they
65+
chose dark (theme.js sets data-theme on <html>; #111). */
6466
@media (prefers-color-scheme: dark) {
65-
:root {
67+
:root:not([data-theme="light"]) {
68+
--bg: #0f1216;
69+
--ink: #e8ecf1;
70+
--muted: #9aa5b3;
71+
--line: #2b323c;
72+
--control-line: #616b7b;
73+
--surface: #161b22;
74+
--accent: #7fd4b4;
75+
--ok: #7fd4b4;
76+
--warn: #e0b060;
77+
--danger: #f08a8a;
78+
--unknown: #9aa5b3;
79+
--fresh: #7fd4b4;
80+
--aging: #e0b060;
81+
--stale: #f08a8a;
82+
}
83+
84+
:root:not([data-theme="light"]) .btn {
85+
border-color: var(--accent);
86+
background: var(--accent);
87+
color: #0f1216;
88+
}
89+
90+
:root:not([data-theme="light"]) .btn:hover {
91+
background: var(--ink);
92+
border-color: var(--ink);
93+
color: #0f1216;
94+
}
95+
96+
:root:not([data-theme="light"]) .chip.is-active {
97+
border-color: var(--accent);
98+
background: var(--accent);
99+
color: #0f1216;
100+
}
101+
102+
}
103+
:root[data-theme="dark"] {
66104
--bg: #0f1216;
67105
--ink: #e8ecf1;
68106
--muted: #9aa5b3;
@@ -79,26 +117,45 @@
79117
--stale: #f08a8a;
80118
}
81119

82-
.btn {
120+
:root[data-theme="dark"] .btn {
83121
border-color: var(--accent);
84122
background: var(--accent);
85123
color: #0f1216;
86124
}
87125

88-
.btn:hover {
126+
:root[data-theme="dark"] .btn:hover {
89127
background: var(--ink);
90128
border-color: var(--ink);
91129
color: #0f1216;
92130
}
93131

94-
.chip.is-active {
132+
:root[data-theme="dark"] .chip.is-active {
95133
border-color: var(--accent);
96134
background: var(--accent);
97135
color: #0f1216;
98136
}
99137

138+
139+
:root[data-theme="light"] {
140+
color-scheme: light;
100141
}
101142

143+
:root[data-theme="dark"] {
144+
color-scheme: dark;
145+
}
146+
147+
.theme-toggle {
148+
border: 0;
149+
background: none;
150+
color: inherit;
151+
font: inherit;
152+
padding: 0;
153+
cursor: pointer;
154+
text-decoration: underline;
155+
text-underline-offset: 0.2em;
156+
}
157+
158+
102159
*,
103160
*::before,
104161
*::after {

‎site/public/theme.js‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Theme choice (#111): system, light or dark, kept in this browser. Loaded
3+
* as a plain blocking script in <head> so the colours are right before the
4+
* first paint; the Content-Security-Policy allows no inline script, so this
5+
* file is it. The header button cycles the three choices. It also registers
6+
* the offline service worker (sw.js). Pages work without it.
7+
*/
8+
(() => {
9+
// This script's own URL: sw.js sits beside it at the site root.
10+
const here = document.currentScript ? document.currentScript.src : document.baseURI;
11+
const KEY = "fgda:theme";
12+
const CHOICES = ["system", "light", "dark"];
13+
const read = () => {
14+
try {
15+
const v = localStorage.getItem(KEY);
16+
return CHOICES.includes(v) ? v : "system";
17+
} catch {
18+
return "system";
19+
}
20+
};
21+
const apply = (choice) => {
22+
if (choice === "system") document.documentElement.removeAttribute("data-theme");
23+
else document.documentElement.setAttribute("data-theme", choice);
24+
};
25+
apply(read());
26+
27+
document.addEventListener("DOMContentLoaded", () => {
28+
const btn = document.getElementById("theme-toggle");
29+
if (btn) {
30+
const label = (c) => `Theme: ${c}`;
31+
btn.hidden = false;
32+
btn.textContent = label(read());
33+
btn.addEventListener("click", () => {
34+
const next = CHOICES[(CHOICES.indexOf(read()) + 1) % CHOICES.length];
35+
try {
36+
if (next === "system") localStorage.removeItem(KEY);
37+
else localStorage.setItem(KEY, next);
38+
} catch {
39+
// Storage off: the choice lasts for this page only.
40+
}
41+
apply(next);
42+
btn.textContent = label(next);
43+
});
44+
}
45+
// Offline copy of the catalog.
46+
if ("serviceWorker" in navigator && (location.protocol === "https:" || location.hostname === "localhost")) {
47+
navigator.serviceWorker.register(new URL("sw.js", here)).catch(() => {});
48+
}
49+
});
50+
})();

0 commit comments

Comments
 (0)