Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions site/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
18 changes: 18 additions & 0 deletions site/scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
cpSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
Expand Down Expand Up @@ -170,6 +171,23 @@ writeFileSync(
join(output, "404.html"),
'<!doctype html><html lang="en" data-theme="dark"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Page not found — Threadboard</title><link rel="stylesheet" href="/styles.css"><main class="wrap section-space"><p class="eyebrow">404 / PAGE NOT FOUND</p><h1>Let’s get you back to the board.</h1><p class="hero-description">This page isn’t here. The product page has the preview and installation guide.</p><div class="hero-actions"><a class="button primary" href="/">Back to Threadboard</a></div></main></html>',
);
// 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("<html ", `<html data-asset-version="${assetVersion}" `)
.replaceAll('"/demo/"', `"/demo/?v=${assetVersion}"`)
.replace(/\/(?:styles\.css|main\.js|assets\/[\w.-]+|demo\/(?:app\.(?:css|js)|demo\.css))(?=["')])/g,
path => `${path}?v=${assetVersion}`);
writeFileSync(join(output, file), content);
}
writeFileSync(
join(output, "build.json"),
JSON.stringify({
Expand Down
3 changes: 2 additions & 1 deletion site/scripts/check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
9 changes: 5 additions & 4 deletions site/src/main.js
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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) => {
Expand Down
22 changes: 21 additions & 1 deletion site/tests/site.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
}) => {
Expand Down Expand Up @@ -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();
Expand Down
Loading