diff --git a/site/README.md b/site/README.md
index e2284b7..f27a0a1 100644
--- a/site/README.md
+++ b/site/README.md
@@ -20,4 +20,6 @@ Cloudflare Pages project: `threadboard-web`. GitHub source: `vrnrn/Threadboard`,
Download and checksum URLs include the package content hash. This keeps cached preview downloads matched to their checksum even when two source builds share a preview version.
+Styles, scripts, demo pages, and product images carry a build content hash in their URLs. Returning visitors receive the matching assets after a deployment, including when switching themes or resetting the demo.
+
The website is independent of Threadboard's fully local runtime. Hosting the page and downloadable installer does not introduce board synchronization or a hosted MCP server.
diff --git a/site/scripts/build.mjs b/site/scripts/build.mjs
index bfe8797..f7fc83f 100644
--- a/site/scripts/build.mjs
+++ b/site/scripts/build.mjs
@@ -2,6 +2,7 @@ import {
cpSync,
mkdirSync,
readFileSync,
+ readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
@@ -170,6 +171,23 @@ writeFileSync(
join(output, "404.html"),
'
Page not found — Threadboard 404 / PAGE NOT FOUND
Let’s get you back to the board. This page isn’t here. The product page has the preview and installation guide.
',
);
+// Keep returning visitors from combining new HTML with cached scripts or art.
+const assetFiles = [
+ "index.html", "404.html", "demo/index.html",
+ "styles.css", "main.js", "demo/app.js", "demo/app.css", "demo/demo.css",
+ ...readdirSync(join(output, "assets")).sort().map(name => `assets/${name}`),
+];
+const assetHash = createHash("sha256");
+for (const file of assetFiles) assetHash.update(file).update(readFileSync(join(output, file)));
+const assetVersion = assetHash.digest("hex").slice(0, 12);
+for (const file of ["index.html", "404.html", "demo/index.html", "styles.css"]) {
+ const content = readFileSync(join(output, file), "utf8")
+ .replace(" `${path}?v=${assetVersion}`);
+ writeFileSync(join(output, file), content);
+}
writeFileSync(
join(output, "build.json"),
JSON.stringify({
diff --git a/site/scripts/check.mjs b/site/scripts/check.mjs
index 798b165..33eb59c 100644
--- a/site/scripts/check.mjs
+++ b/site/scripts/check.mjs
@@ -25,7 +25,8 @@ for (const match of html.matchAll(/\b(?:href|src)="([^"]+)"/g)) {
assert.ok(ids.has(reference.slice(1)), `Missing anchor ${reference}`);
continue;
}
- const path = reference === "/" ? "/index.html" : reference;
+ const pathname = new URL(reference, "https://threadboard.vrnrn.com").pathname;
+ const path = pathname === "/" ? "/index.html" : pathname;
assert.ok(
existsSync(resolve(output, `.${path}`)),
`Missing asset: ${reference}`,
diff --git a/site/src/main.js b/site/src/main.js
index ade0529..6bd2afb 100644
--- a/site/src/main.js
+++ b/site/src/main.js
@@ -1,4 +1,6 @@
const root = document.documentElement;
+const assetVersion = root.dataset.assetVersion;
+const demoSource = () => `/demo/?theme=${root.dataset.theme}&v=${assetVersion}`;
const announcement = document.querySelector("#announcement");
const themeToggle = document.querySelector(".theme-toggle");
const screenshotNames = {
@@ -17,10 +19,10 @@ function applyTheme(theme) {
theme === "dark" ? "#181818" : "#f7f5f0";
for (const image of document.querySelectorAll("[data-theme-image]"))
image.src = imageSource(image.dataset.themeImage);
- document.querySelector("[data-open-demo]").href = `/demo/?theme=${theme}`;
+ document.querySelector("[data-open-demo]").href = demoSource();
}
function imageSource(name) {
- return `/assets/${name}${root.dataset.theme === "dark" ? "-dark" : ""}.png`;
+ return `/assets/${name}${root.dataset.theme === "dark" ? "-dark" : ""}.png?v=${assetVersion}`;
}
applyTheme(root.dataset.theme);
themeToggle.addEventListener("click", () => {
@@ -164,8 +166,7 @@ imageDialog.addEventListener("close", () => {
imageTrigger?.focus();
});
document.querySelector("[data-reset-demo]").addEventListener("click", () => {
- document.querySelector("#app-demo").src =
- `/demo/?theme=${root.dataset.theme}`;
+ document.querySelector("#app-demo").src = demoSource();
announcement.textContent = "Demo board reset to its example projects.";
});
window.addEventListener("message", (event) => {
diff --git a/site/tests/site.spec.ts b/site/tests/site.spec.ts
index 4c88297..5204751 100644
--- a/site/tests/site.spec.ts
+++ b/site/tests/site.spec.ts
@@ -1,6 +1,26 @@
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
import { createHash } from "node:crypto";
+test("returning visitors bypass old unversioned styles and demo assets", async ({ page }) => {
+ const staleRequests: string[] = [];
+ await page.route("**/*", async route => {
+ const url = new URL(route.request().url());
+ const stale = ["/styles.css", "/main.js", "/demo/", "/demo/app.js", "/demo/app.css", "/demo/demo.css"];
+ if (stale.includes(url.pathname) && !url.searchParams.has("v")) {
+ staleRequests.push(url.pathname);
+ await route.fulfill({ status: 410, body: "Old cached asset" });
+ } else await route.continue();
+ });
+ await page.goto("/");
+ await expect(page.getByRole("heading", { name: "Task boards. Inside Codex." })).toBeVisible();
+ await page.getByRole("button", { name: "Switch to light mode" }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
+ await page.getByRole("button", { name: "Reset demo board" }).click();
+ const app = page.frameLocator("#app-demo");
+ await expect(app.getByRole("heading", { name: "Guide users through their first task" })).toBeVisible();
+ expect(staleRequests).toEqual([]);
+});
+
test("dark is the default; theme choice and product images survive navigation", async ({
page,
}) => {
@@ -49,7 +69,7 @@ test("the example handoff supports pointer and keyboard; screenshot dialog retur
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.locator("#large-image")).toHaveAttribute(
"src",
- "/assets/task-review-dark.png",
+ /\/assets\/task-review-dark\.png\?v=[a-f0-9]{12}$/,
);
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).not.toBeVisible();