feat(site): link the live demo + serve the gerber demo from the versioned CDN

Refer to demo.pcbjam.com from the landing: an accent "Live demo" link in the
header nav, an "Open the live editor" hero link, a note in the gerber section,
and a "Try the live demo now" line in the final CTA.

Rework the embedded gerber viewer (public/gerber-demo/boot.js) to source the
WASM from the deploy pipeline's versioned CDN instead of the hand-synced
assets.pcbjam.com bucket: resolve gerbview's content-addressed folder at runtime
from the release manifest (manifest-latest -> tag -> manifest-<tag> -> gerbview),
load all assets (glue + wasm + images.tar.gz) from cdn.pcbjam.com, and load the
cross-origin pthread worker via a same-origin blob importScripts shim (mirrors
web/standalone/src/wasm/boot.ts). Bump the config-seed KICAD_VERSION_DIR
9.99 -> 10.0 to match the deployed build. Drop the now-obsolete committed glue
mirror and the old assets.pcbjam.com sync/r2-deploy scripts.

Collapse the header nav to the hamburger below 1025px — the added demo link no
longer fits the 1024px-capped bar on a single row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-07-01 20:06:51 +02:00 committed by Viktor Vaczi
commit 9b41d7321f
15 changed files with 407 additions and 17539 deletions

7
site/.gitignore vendored
View file

@ -2,13 +2,6 @@
dist/
.vercel/
# Gerber-viewer demo WASM (synced from /output by scripts/sync-demo-wasm.sh).
# The big binaries are served from Cloudflare R2 in production — never commit /
# ship them to Vercel. The small glue JS (wx.js, wx-dom.js, gerbview.js) MUST be
# committed: it's served same-origin (the pthread worker can't be cross-origin).
public/gerber-demo/wasm/gerbview.wasm
public/gerber-demo/wasm/kicad-resources.bin
# generated content collection types & cache
.astro/

View file

@ -1,38 +1,65 @@
# Gerber viewer blog demo
# Gerber viewer demo
KiCad's `gerbview` compiled to WebAssembly, embedded (lazily) in the blog post.
Click → it streams the WASM and renders the bundled tiny_tapeout board in-browser.
KiCad's `gerbview` compiled to WebAssembly, embedded (lazily) in the landing page
and the blog post. Click → it streams the WASM and renders the bundled
tiny_tapeout board in-browser.
The WASM is **not** kept here — `boot.js` loads it from the versioned CDN
(`cdn.pcbjam.com`), the same artifacts the demo/app deploy publishes. It resolves
gerbview's immutable, content-addressed folder at runtime from the release
manifest, so this page always shows the **latest deployed** gerbview with no
manual sync:
```
manifest-latest.json -> { tag }
manifest-<tag>.json -> tools.gerbview -> <ver>
base = https://cdn.pcbjam.com/wasm/gerbview/<ver>
```
See `docs/features/demo-deploy/0001-wasm-cdn-versioning.md` (in `pcbjam-private`)
for the CDN layout, manifest shapes, and header matrix.
## Files here (`site/public/gerber-demo/`)
| Path | What it does |
|------|--------------|
| `index.html` | The iframe target — minimal page with the `#main-window` / `#window-container` the WASM needs. |
| `boot.js` | Boot harness: configures Emscripten `Module`, seeds KiCad config, preloads the board into MEMFS, auto-opens it via `Module.arguments`, and injects `wx.js → wx-dom.js → gerbview.js`. Holds `R2_BASE` + the dev/prod asset-base switch. |
| `index.html` | The iframe/standalone target — minimal page with the `#main-window` / `#window-container` the WASM needs. |
| `boot.js` | Boot harness: resolves gerbview's CDN folder from the manifest, configures Emscripten `Module`, seeds KiCad config, preloads the board into MEMFS, auto-opens it via `Module.arguments`, and injects `wx.js → wx-dom.js → gerbview.js` from the CDN. |
| `board/` | The tiny_tapeout Gerber layers (committed) the demo opens. |
| `poster.png` | Static fallback shown to browsers that can't run the live viewer. |
| `wasm/` | `wx.js`/`wx-dom.js`/`gerbview.js` (committed, served same-origin) + `gerbview.wasm`/`kicad-resources.bin` (git-ignored; served from R2 in prod, local in dev). Synced from `/output`. |
The folder is cross-origin to this page (which is COEP `require-corp`); the CDN
sends `Cross-Origin-Resource-Policy: cross-origin` + `Access-Control-Allow-Origin: *`,
and the cross-origin pthread worker is loaded via a same-origin `blob:`
`importScripts` shim (`new Worker(<cross-origin URL>)` is a SecurityError). This
mirrors the standalone editor's `web/standalone/src/wasm/boot.ts`.
## Related pieces (elsewhere in `site/`)
| Path | What it does |
|------|--------------|
| `src/components/GerberDemo.astro` | The embed: lazy click-to-load iframe, cross-origin-isolation reload guard, feature-detect + poster fallback. |
| `src/content/blog/porting-kicad-graphics-to-webgl-with-claude.mdx` | The post that renders `<GerberDemo />`. |
| `src/sections/GerberDemoSection.astro` | The landing-page showcase: a poster + launch button that opens `/gerber-demo/` in a new tab (the landing itself is not cross-origin isolated). |
| `src/components/GerberDemo.astro` | The blog embed: lazy click-to-load iframe, cross-origin-isolation reload guard, feature-detect + poster fallback. |
| `astro.config.mjs` + `src/middleware.ts` | Dev cross-origin-isolation headers (COOP/COEP `require-corp`). |
| `vercel.json` | Prod COOP/COEP, scoped to the post + `/gerber-demo/` routes. |
| `scripts/sync-demo-wasm.sh` | Copy fresh WASM from `/output` into `wasm/` (run after a gerbview rebuild). |
| `scripts/r2-deploy.sh` + `scripts/r2-cors.json` | Upload the heavy binaries to Cloudflare R2 (`pcbjam-assets``assets.pcbjam.com`) and set CORS. |
| `vercel.json` | Prod COOP/COEP, scoped to the blog post + `/gerber-demo/` routes. |
## Run / update
## Dev overrides
```bash
# local dev (serves wasm/ from the local mirror)
scripts/sync-demo-wasm.sh && npm run dev # /blog/porting-kicad-graphics-to-webgl-with-claude
`boot.js` reads query params so you can point it elsewhere without a rebuild:
# after a new gerbview build: refresh local mirror, then push binaries to R2
scripts/sync-demo-wasm.sh && scripts/r2-deploy.sh
```
| Param | Effect |
|-------|--------|
| `?tag=<tag>` | Pin a specific release instead of following `manifest-latest.json`. |
| `?cdn=<root>` | Swap the CDN root (e.g. a local mirror serving `manifest-*.json` + `gerbview/<ver>/`). |
| `?base=<folder>` | Use a tool folder verbatim (e.g. a fresh local build) — skips manifest resolution. |
The live viewer needs SharedArrayBuffer + WebGL2 (Chrome/Edge/Firefox, Safari 15.2+);
other browsers get `poster.png`.
other browsers get `poster.png`.
## Updating `KICAD_VERSION_DIR`
`boot.js` seeds KiCad config under a version dir (currently `"10.0"`) to suppress
the first-run wizard. It **must** match the deployed build's
`GetMajorMinorVersion()`. If a future deploy bumps KiCad's major.minor, update the
`KICAD_VERSION_DIR` constant in `boot.js` (same coupling as
`web/standalone/src/wasm/constants.ts`).

View file

@ -1,44 +1,51 @@
/*
* Self-contained boot harness for the KiCad Gerber viewer (gerbview) WASM,
* embedded in the blog post via an <iframe>. Adapted from the proven test
* harness (tests/apps/kicad/gerbview.html) and the React port
* (web/standalone/src/wasm/boot.ts) same global `Module`, same preRun steps,
* same wx.js -> wx-dom.js -> gerbview.js injection order.
* embedded in the landing page / blog post via an <iframe>. A faithful port of
* the standalone React boot (web/standalone/src/wasm/boot.ts) same global
* `Module`, same preRun steps, same wx.js -> wx-dom.js -> gerbview.js order.
*
* Classic script (NOT a module): the non-modularized WASM glue reads a GLOBAL
* `var Module` and a GLOBAL `mainWindow`, and publishes `FS` onto the global
* scope. Top-level `var`/`const` here share that global scope.
* scope. `window.Module = ...` is what makes the glue see our config.
*
* Asset layout, split because the pthread worker script MUST be same-origin
* (a Worker can't be created from a cross-origin URL). So the small glue stays
* on the site origin and only the big binaries move to Cloudflare R2:
* GLUE_BASE : wx.js, wx-dom.js, gerbview.js, mainScriptUrlOrBlob (same-origin)
* BINARY_BASE : gerbview.wasm, kicad-resources.bin (R2 in prod, local in dev)
* Assets come from the versioned CDN (cdn.pcbjam.com) the SAME artifacts the
* demo/app deploy publishes, not a hand-synced copy. We resolve gerbview's
* immutable, content-addressed folder at runtime from the release manifest:
*
* BINARY_BASE auto-switches by hostname: localhost the local mirror (./wasm),
* any other host R2_BASE below. Override either base with ?glue=/?bin=.
* To point at R2, set R2_BASE to your bucket's public URL either a custom
* domain (https://assets.pcbjam.com) or the managed https://<id>.r2.dev URL.
* manifest-latest.json -> { tag }
* manifest-<tag>.json -> tools.gerbview -> <ver>
* base = <CDN_ROOT>/gerbview/<ver>
*
* so the landing always shows the LATEST deployed gerbview with no manual sync.
* A tool folder is self-contained + ABI-matched: gerbview.wasm, gerbview.js,
* wx.js, wx-dom.js, images.tar.gz. See docs/features/demo-deploy/0001-*.
*
* The folder is CROSS-ORIGIN to this page (which is COEP `require-corp`); the CDN
* sends `Cross-Origin-Resource-Policy: cross-origin` + `Access-Control-Allow-Origin: *`
* so the <script>/fetch loads are permitted. The one thing a cross-origin base
* breaks is the pthread worker `new Worker(<cross-origin URL>)` is a
* SecurityError so `mainScriptUrlOrBlob` is a SAME-ORIGIN `blob:` worker that
* `importScripts()` the cross-origin glue (a blob URL inherits the page origin;
* a classic worker's importScripts is allowed cross-origin under CORP).
*
* Dev overrides (query string): ?base=<folder> uses that folder verbatim (e.g. a
* local build); ?tag=<tag> pins a specific release; ?cdn=<root> swaps the CDN root.
*/
(function () {
"use strict";
// R2 bucket public URL (no trailing slash). Custom domain for the
// pcbjam-assets bucket (the managed URL https://pub-cecc0239e6f74d99ba7d06630bd87c64.r2.dev
// also still works).
var R2_BASE = "https://assets.pcbjam.com";
// Versioned WASM CDN root (no trailing slash). Overridable with ?cdn=.
var CDN_ROOT = "https://cdn.pcbjam.com/wasm";
var params = new URLSearchParams(location.search);
var isLocal = /^(localhost|127\.0\.0\.1|\[?::1\]?)$/.test(location.hostname);
var GLUE_BASE = (params.get("glue") || "./wasm").replace(/\/+$/, "");
var BINARY_BASE = (params.get("bin") || (isLocal ? "./wasm" : R2_BASE)).replace(
/\/+$/,
""
);
// KiCad paths baked into the WASM build (see web/standalone/src/wasm/constants.ts).
var KICAD_VERSION_DIR = "9.99";
// KICAD_VERSION_DIR MUST match the deployed build's GetMajorMinorVersion() — the
// config we seed to suppress the first-run wizard is only read from THIS dir.
// The KiCad 10.0.x rebase bumped it from "9.99" to "10.0"; bump it here if a
// future deploy changes KiCad's major.minor.
var KICAD_VERSION_DIR = "10.0";
var KICAD_CONFIG_DIR =
"/home/kicad/.config/kicad/kicad/" + KICAD_VERSION_DIR;
var RESOURCE_PATH =
@ -99,49 +106,35 @@
var mainWindow = document.getElementById("main-window");
window.mainWindow = mainWindow;
// ── Prefetch the heavy, non-wasm assets in parallel with the wasm download ──
// images.tar.gz (compiled-in KiCad resources) and the board layers. Both are
// ~10x smaller than gerbview.wasm, so they land before preRun runs; a run
// dependency guards the rare case where the wasm wins the race.
var resourceData = null;
// Served without a .gz extension on purpose (see sync-demo-wasm.sh) so no
// server adds Content-Encoding: gzip. Written into MEMFS as images.tar.gz.
fetch(BINARY_BASE + "/kicad-resources.bin")
.then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.arrayBuffer();
})
.then(function (buf) {
resourceData = new Uint8Array(buf);
console.log("[KICAD] prefetched images.tar.gz (" + resourceData.length + " bytes)");
})
.catch(function (err) {
console.warn("[KICAD] images.tar.gz prefetch failed:", err.message);
// ── Resolve gerbview's versioned CDN folder from the release manifest ────────
function fetchJson(url) {
// manifest-*.json are served `no-store` so a rollback takes effect next load.
return fetch(url, { cache: "no-store" }).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status + " for " + url);
return r.json();
});
}
var boardData = null; // { name: Uint8Array }
var boardPromise = Promise.all(
BOARD_FILES.map(function (name) {
return fetch("./board/" + name)
.then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status + " for " + name);
return r.arrayBuffer();
})
.then(function (buf) {
return [name, new Uint8Array(buf)];
function resolveBase() {
var override = params.get("base");
if (override) return Promise.resolve(override.replace(/\/+$/, ""));
var root = (params.get("cdn") || CDN_ROOT).replace(/\/+$/, "");
var tagParam = params.get("tag");
var tagP = tagParam
? Promise.resolve(tagParam)
: fetchJson(root + "/manifest-latest.json").then(function (m) {
if (!m || !m.tag) throw new Error("manifest-latest.json has no tag");
return m.tag;
});
})
)
.then(function (pairs) {
boardData = {};
pairs.forEach(function (p) {
boardData[p[0]] = p[1];
return tagP.then(function (tag) {
return fetchJson(root + "/manifest-" + tag + ".json").then(function (m) {
var ver = m && m.tools && m.tools.gerbview;
if (!ver) throw new Error("no gerbview version in manifest-" + tag);
console.log("[KICAD] gerbview " + ver + " (release " + tag + ")");
return root + "/gerbview/" + ver;
});
console.log("[KICAD] prefetched " + pairs.length + " board files");
})
.catch(function (err) {
console.error("[KICAD] board prefetch failed:", err.message);
});
}
// ── preRun steps ────────────────────────────────────────────────────────────
function createCanvas() {
@ -166,16 +159,6 @@
console.log("[KICAD] canvas " + window.innerWidth + "x" + window.innerHeight);
}
function writeResources() {
FS.mkdirTree(RESOURCE_PATH);
if (resourceData) {
FS.writeFile(RESOURCE_PATH + "/images.tar.gz", resourceData);
console.log("[KICAD] wrote images.tar.gz");
} else {
console.warn("[KICAD] images.tar.gz not ready at preRun");
}
}
// Suppress the first-run setup wizard (its modal loop crashes Asyncify on our
// ephemeral MEMFS): make every settings provider report NeedsUserInput()==false.
function seedKicadConfig() {
@ -206,86 +189,174 @@
console.log("[KICAD] seeded config (wizard suppressed)");
}
// Write the board into MEMFS and point argv at it so gerbview auto-opens it.
// argv must be set before main(); a run dependency keeps main() waiting if the
// board fetch hasn't landed yet.
function preloadBoard() {
Module.arguments = OPEN_ARGS;
var writeBoard = function () {
if (!boardData) return;
FS.mkdirTree(BOARD_DIR);
Object.keys(boardData).forEach(function (name) {
FS.writeFile(BOARD_DIR + "/" + name, boardData[name]);
// Boot the tool once its CDN folder is resolved. `base` is the (cross-origin)
// versioned folder; every asset — glue, wasm, images.tar.gz — lives under it.
function boot(base) {
// ── Prefetch the heavy, non-wasm assets in parallel with the wasm download ──
// images.tar.gz (compiled-in KiCad resources) comes from the tool folder; the
// board layers are same-origin fixtures. Both are far smaller than
// gerbview.wasm, so they land before preRun runs; a run dependency guards the
// rare case where the wasm wins the race.
var resourceData = null;
// The CDN stores images.tar.gz as raw gzip with NO Content-Encoding, so the
// browser hands us the compressed bytes and KiCad's own gunzip succeeds.
fetch(base + "/images.tar.gz")
.then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.arrayBuffer();
})
.then(function (buf) {
resourceData = new Uint8Array(buf);
console.log("[KICAD] prefetched images.tar.gz (" + resourceData.length + " bytes)");
})
.catch(function (err) {
console.warn("[KICAD] images.tar.gz prefetch failed:", err.message);
});
console.log("[KICAD] wrote board into " + BOARD_DIR + "; argv=" + OPEN_ARGS.length + " files");
};
if (boardData) {
writeBoard();
return;
var boardData = null; // { name: Uint8Array }
var boardPromise = Promise.all(
BOARD_FILES.map(function (name) {
return fetch("./board/" + name)
.then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status + " for " + name);
return r.arrayBuffer();
})
.then(function (buf) {
return [name, new Uint8Array(buf)];
});
})
)
.then(function (pairs) {
boardData = {};
pairs.forEach(function (p) {
boardData[p[0]] = p[1];
});
console.log("[KICAD] prefetched " + pairs.length + " board files");
})
.catch(function (err) {
console.error("[KICAD] board prefetch failed:", err.message);
});
function writeResources() {
FS.mkdirTree(RESOURCE_PATH);
if (resourceData) {
FS.writeFile(RESOURCE_PATH + "/images.tar.gz", resourceData);
console.log("[KICAD] wrote images.tar.gz");
} else {
console.warn("[KICAD] images.tar.gz not ready at preRun");
}
}
var add = window.addRunDependency;
var rm = window.removeRunDependency;
if (typeof add === "function" && typeof rm === "function") {
add("board-files");
boardPromise.then(function () {
// Write the board into MEMFS and point argv at it so gerbview auto-opens it.
// argv must be set before main(); a run dependency keeps main() waiting if the
// board fetch hasn't landed yet.
function preloadBoard() {
Module.arguments = OPEN_ARGS;
var writeBoard = function () {
if (!boardData) return;
FS.mkdirTree(BOARD_DIR);
Object.keys(boardData).forEach(function (name) {
FS.writeFile(BOARD_DIR + "/" + name, boardData[name]);
});
console.log("[KICAD] wrote board into " + BOARD_DIR + "; argv=" + OPEN_ARGS.length + " files");
};
if (boardData) {
writeBoard();
rm("board-files");
});
} else {
// Best-effort fallback (board << wasm, so this path is unlikely to lose).
boardPromise.then(writeBoard);
return;
}
var add = window.addRunDependency;
var rm = window.removeRunDependency;
if (typeof add === "function" && typeof rm === "function") {
add("board-files");
boardPromise.then(function () {
writeBoard();
rm("board-files");
});
} else {
// Best-effort fallback (board << wasm, so this path is unlikely to lose).
boardPromise.then(writeBoard);
}
}
// The pthread worker "script" for Module.mainScriptUrlOrBlob. KiCad spawns
// CLASSIC workers via `new Worker(...)`; a cross-origin URL is a SecurityError,
// so for the CDN base we hand emscripten a SAME-ORIGIN blob worker that
// importScripts the cross-origin glue (allowed because the CDN sends CORP).
function pthreadWorkerScript() {
var abs = new URL(base + "/gerbview.js", location.href);
if (abs.origin === location.origin) return base + "/gerbview.js";
return new Blob(["importScripts(" + JSON.stringify(abs.href) + ");"], {
type: "text/javascript",
});
}
// ── Module config (global) ─────────────────────────────────────────────────
var Module = {
thisProgram: "/usr/bin/gerbview", // argv[0] for KiCad's DEBUG check
arguments: OPEN_ARGS,
preRun: [createCanvas, writeResources, seedKicadConfig, preloadBoard],
postRun: [],
print: function () {
console.log("[KICAD_OUT] " + Array.prototype.join.call(arguments, " "));
},
printErr: function () {
console.error("[KICAD_ERR] " + Array.prototype.join.call(arguments, " "));
},
setStatus: function (text) {
if (text) console.log("[KICAD_STATUS] " + text);
setStatusUI(text);
},
totalDependencies: 0,
monitorRunDependencies: function (left) {
this.totalDependencies = Math.max(this.totalDependencies, left);
Module.setStatus(
left
? "Preparing… (" + (this.totalDependencies - left) + "/" + this.totalDependencies + ")"
: "All downloads complete."
);
},
onRuntimeInitialized: function () {
console.log("[KICAD] runtime initialized");
if (Module.canvas) Module.canvas.style.display = "block";
hideStatus();
},
onAbort: function (what) {
showError("aborted: " + (what === undefined ? "" : String(what)));
},
// Everything (the .wasm and the pthread worker's relative fetches) resolves
// against the versioned CDN folder.
locateFile: function (path) {
return base + "/" + path;
},
// Pin the pthread worker: same-origin base → direct URL; cross-origin CDN →
// a same-origin blob shim that importScripts the glue (see helper above).
mainScriptUrlOrBlob: pthreadWorkerScript(),
};
window.Module = Module;
Module.setStatus("Downloading…");
window.onerror = function (msg, url, line) {
showError(msg + " @ " + url + ":" + line);
return false;
};
// ── Inject glue scripts in the required order ──────────────────────────────
// wx.js → wx-dom.js → gerbview.js, all from the (cross-origin) CDN folder.
loadScript(base + "/wx.js")
.then(function () {
return loadScript(base + "/wx-dom.js");
})
.then(function () {
return loadScript(base + "/gerbview.js");
})
.then(function () {
console.log("[KICAD] injected wx.js + wx-dom.js + gerbview.js (base=" + base + ")");
})
.catch(function (err) {
showError(err.message);
});
}
// ── Module config (global) ───────────────────────────────────────────────────
var Module = {
thisProgram: "/usr/bin/gerbview", // argv[0] for KiCad's DEBUG check
arguments: OPEN_ARGS,
preRun: [createCanvas, writeResources, seedKicadConfig, preloadBoard],
postRun: [],
print: function () {
console.log("[KICAD_OUT] " + Array.prototype.join.call(arguments, " "));
},
printErr: function () {
console.error("[KICAD_ERR] " + Array.prototype.join.call(arguments, " "));
},
setStatus: function (text) {
if (text) console.log("[KICAD_STATUS] " + text);
setStatusUI(text);
},
totalDependencies: 0,
monitorRunDependencies: function (left) {
this.totalDependencies = Math.max(this.totalDependencies, left);
Module.setStatus(
left
? "Preparing… (" + (this.totalDependencies - left) + "/" + this.totalDependencies + ")"
: "All downloads complete."
);
},
onRuntimeInitialized: function () {
console.log("[KICAD] runtime initialized");
if (Module.canvas) Module.canvas.style.display = "block";
hideStatus();
},
onAbort: function (what) {
showError("aborted: " + (what === undefined ? "" : String(what)));
},
// Route the .wasm to BINARY_BASE; everything else (the pthread worker) to GLUE_BASE.
locateFile: function (path) {
return (/\.wasm$/.test(path) ? BINARY_BASE : GLUE_BASE) + "/" + path;
},
// Pin the pthread worker to the same-origin glue (workers cannot be cross-origin).
mainScriptUrlOrBlob: GLUE_BASE + "/gerbview.js",
};
window.Module = Module;
Module.setStatus("Downloading…");
window.onerror = function (msg, url, line) {
showError(msg + " @ " + url + ":" + line);
return false;
};
// ── Inject glue scripts in the required order ────────────────────────────────
function loadScript(src) {
return new Promise(function (resolve, reject) {
var s = document.createElement("script");
@ -298,17 +369,10 @@
});
}
loadScript(GLUE_BASE + "/wx.js")
.then(function () {
return loadScript(GLUE_BASE + "/wx-dom.js");
})
.then(function () {
return loadScript(GLUE_BASE + "/gerbview.js");
})
.then(function () {
console.log("[KICAD] injected wx.js + wx-dom.js + gerbview.js (glue=" + GLUE_BASE + ", bin=" + BINARY_BASE + ")");
})
setStatusUI("Resolving latest build…");
resolveBase()
.then(boot)
.catch(function (err) {
showError(err.message);
showError("could not resolve gerbview build: " + err.message);
});
})();

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,18 +0,0 @@
{
"rules": [
{
"allowed": {
"origins": [
"https://pcbjam.com",
"https://www.pcbjam.com",
"https://*.vercel.app",
"http://localhost:4321"
],
"methods": ["GET", "HEAD"],
"headers": ["*"]
},
"exposeHeaders": ["Content-Length", "Content-Encoding", "Content-Type"],
"maxAgeSeconds": 86400
}
]
}

View file

@ -1,59 +0,0 @@
#!/usr/bin/env bash
# Deploy the heavy Gerber-viewer binaries to Cloudflare R2.
#
# Prereqs (one-time, done by you — see the chat guide):
# 1. R2 enabled on your Cloudflare account (dashboard; needs a payment method).
# 2. `wrangler login` (browser auth).
#
# This script then (idempotent — safe to re-run after a rebuild):
# - creates the bucket (skips if it already exists)
# - sets CORS (site/scripts/r2-cors.json)
# - enables the public r2.dev URL and prints it
# - gzips gerbview.wasm and uploads it (Content-Encoding: gzip) + kicad-resources.bin
#
# Usage: site/scripts/r2-deploy.sh [bucket-name] (default: pcbjam-assets)
set -euo pipefail
BUCKET="${1:-pcbjam-assets}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # = site/
SRC="$ROOT/public/gerber-demo/wasm"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
command -v wrangler >/dev/null || { echo "wrangler not found (npm i -g wrangler)"; exit 1; }
wrangler whoami >/dev/null 2>&1 || { echo "Not logged in. Run: wrangler login"; exit 1; }
[[ -f "$SRC/gerbview.wasm" ]] || { echo "Missing $SRC/gerbview.wasm — run scripts/sync-demo-wasm.sh"; exit 1; }
[[ -f "$SRC/kicad-resources.bin" ]] || { echo "Missing $SRC/kicad-resources.bin — run scripts/sync-demo-wasm.sh"; exit 1; }
echo "==> Bucket: $BUCKET"
wrangler r2 bucket create "$BUCKET" 2>/dev/null \
&& echo " created" \
|| echo " already exists (continuing)"
echo "==> CORS"
wrangler r2 bucket cors set "$BUCKET" --file "$ROOT/scripts/r2-cors.json"
echo "==> Public r2.dev URL (makes the bucket's objects publicly readable)"
wrangler r2 bucket dev-url enable "$BUCKET" || true
wrangler r2 bucket dev-url get "$BUCKET" || true
echo "==> gzip gerbview.wasm"
gzip -9 -c "$SRC/gerbview.wasm" > "$TMP/gerbview.wasm.gz"
echo " $(du -h "$SRC/gerbview.wasm" | cut -f1) -> $(du -h "$TMP/gerbview.wasm.gz" | cut -f1) gzipped"
echo "==> Upload gerbview.wasm (gzip, application/wasm)"
wrangler r2 object put "$BUCKET/gerbview.wasm" --remote \
--file "$TMP/gerbview.wasm.gz" \
--content-type "application/wasm" \
--content-encoding "gzip" \
--cache-control "public, max-age=3600"
echo "==> Upload kicad-resources.bin (octet-stream, no encoding)"
wrangler r2 object put "$BUCKET/kicad-resources.bin" --remote \
--file "$SRC/kicad-resources.bin" \
--content-type "application/octet-stream" \
--cache-control "public, max-age=3600"
echo ""
echo "Done. Copy the r2.dev URL printed above (https://<hash>.r2.dev) into"
echo "site/public/gerber-demo/boot.js -> R2_BASE, then redeploy the site."
echo "(Or bind a custom domain: wrangler r2 bucket domain add $BUCKET --domain assets.pcbjam.com)"

View file

@ -1,36 +0,0 @@
#!/usr/bin/env bash
# Mirror the gerbview WASM build outputs into the blog demo's local asset dir.
#
# Phase A (local verification) serves the viewer from these files. They are
# git-ignored (see site/.gitignore): the 52 MB gerbview.wasm must never be
# committed or shipped to Vercel — in production the heavy binaries live on
# Cloudflare R2 (see the blog-demo plan, Phase B). Re-run this after rebuilding
# gerbview to refresh the local copy.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SRC="$ROOT/output"
DST="$ROOT/site/public/gerber-demo/wasm"
mkdir -p "$DST"
for f in wx.js wx-dom.js gerbview.js gerbview.wasm images.tar.gz; do
if [[ ! -f "$SRC/$f" ]]; then
echo "ERROR: missing $SRC/$f — build gerbview first (docker/build.sh)." >&2
exit 1
fi
done
cp "$SRC/wx.js" "$DST/wx.js"
cp "$SRC/wx-dom.js" "$DST/wx-dom.js"
cp "$SRC/gerbview.js" "$DST/gerbview.js"
cp "$SRC/gerbview.wasm" "$DST/gerbview.wasm"
# Store the resources tarball WITHOUT a .gz extension: dev servers (Vite/sirv)
# and some CDNs auto-add `Content-Encoding: gzip` for `*.gz`, which makes the
# browser pre-decompress it — then KiCad's own gunzip fails. boot.js fetches
# this name and writes it into MEMFS as images.tar.gz.
cp "$SRC/images.tar.gz" "$DST/kicad-resources.bin"
for f in wx.js wx-dom.js gerbview.js gerbview.wasm kicad-resources.bin; do
echo "synced $f ($(du -h "$DST/$f" | cut -f1))"
done
echo "Local demo WASM ready at $DST"

View file

@ -11,6 +11,9 @@ const links = [
label: 'Built by Emergence Engineering',
external: true,
},
// Highlighted (accent) link to the public editor demo. A compact text link, not
// a button — the header container caps at 1024px and a second button overflows it.
{ href: 'https://demo.pcbjam.com', label: 'Live demo', external: true, highlight: true },
];
const path = Astro.url.pathname;
@ -36,6 +39,7 @@ const isActive = (href: string, external?: boolean) =>
links.map((l) => (
<a
href={l.href}
class:list={[{ 'nav-cta': l.highlight }]}
aria-current={isActive(l.href, l.external) ? 'page' : undefined}
rel={l.external ? 'noopener' : undefined}
target={l.external ? '_blank' : undefined}
@ -87,11 +91,12 @@ const isActive = (href: string, external?: boolean) =>
.nav {
display: flex;
align-items: center;
gap: 1.1rem;
gap: 1rem;
}
.nav > a {
color: var(--fg-muted);
font-size: 0.95rem;
white-space: nowrap;
}
.nav > a:hover {
color: var(--fg);
@ -100,6 +105,15 @@ const isActive = (href: string, external?: boolean) =>
.nav > a[aria-current='page'] {
color: var(--fg);
}
/* Highlighted demo link — accent-colored so it stands out among the neutral
nav links without needing a second button (which would overflow the bar). */
.nav > a.nav-cta {
color: var(--accent);
font-weight: 600;
}
.nav > a.nav-cta:hover {
filter: brightness(1.1);
}
.cta-join {
padding-block: 0.5rem;
font-size: 0.95rem;
@ -120,7 +134,10 @@ const isActive = (href: string, external?: boolean) =>
border-radius: 2px;
}
@media (max-width: 820px) {
/* Collapse to the hamburger menu once the full one-line nav no longer fits the
1024px-capped bar (adding the demo link pushed the crowded desktop nav past
the point where it stays on a single row). */
@media (max-width: 1024px) {
.brand-chip {
display: none;
}

View file

@ -16,6 +16,13 @@ import WaitlistForm from '../components/WaitlistForm.astro';
microcopy="Get early-access invites and product updates. No spam, unsubscribe anytime."
/>
<p class="final-demo">
Dont want to wait? <a
href="https://demo.pcbjam.com"
target="_blank"
rel="noopener">Try the live demo now →</a>
</p>
<ul class="final-trust">
<li>Open source</li>
<li>Built on KiCad</li>
@ -37,6 +44,15 @@ import WaitlistForm from '../components/WaitlistForm.astro';
margin-top: 1.5rem;
text-align: left;
}
.final-demo {
margin: 1rem 0 0;
font-size: 0.95rem;
color: var(--fg-muted);
}
.final-demo a {
color: var(--accent);
font-weight: 600;
}
.final-trust {
list-style: none;
display: flex;

View file

@ -15,6 +15,8 @@ import SectionBand from '../components/SectionBand.astro';
// in a new tab so the landing page itself never needs isolation.
const DEMO_HREF = '/gerber-demo/index.html';
const poster = '/gerber-demo/poster.png';
// The full in-browser editor (not just the viewer) — the public demo.
const EDITOR_URL = 'https://demo.pcbjam.com';
---
<SectionBand id="demo" labelledby="gerber-demo-h2">
@ -40,6 +42,12 @@ const poster = '/gerber-demo/poster.png';
<span class="gerber-launch__cta">Launch the live Gerber viewer</span>
<span class="gerber-launch__sub">Opens in a new tab · streams ~22&nbsp;MB on demand</span>
</a>
<p class="demo-note">
Want to do more than look? Open a board in the full editor at
<a href={EDITOR_URL} target="_blank" rel="noopener">demo.pcbjam.com</a> — edit
the PCB or schematic in your browser, no install, no account.
</p>
</SectionBand>
<style>
@ -99,6 +107,16 @@ const poster = '/gerber-demo/poster.png';
text-align: center;
padding: 0 1rem;
}
.demo-note {
margin: 1rem 0 0;
text-align: center;
color: var(--fg-muted);
font-size: 0.95rem;
}
.demo-note a {
color: var(--accent);
font-weight: 600;
}
@media (prefers-reduced-motion: reduce) {
.gerber-launch {
transition: none;

View file

@ -18,6 +18,15 @@ import Icon from '../components/Icon.astro';
<WaitlistForm source="hero" id="waitlist" class="hero-form" />
<p class="hero-secondary">
<a
href="https://demo.pcbjam.com"
class="try"
target="_blank"
rel="noopener"
>
Open the live editor →
</a>
<span class="dot" aria-hidden="true">·</span>
<a href="#multiplayer" class="watch">
<Icon name="icons/play.svg" size="1.1rem" alt="" /> Watch the 90-second demo →
</a>
@ -61,6 +70,19 @@ import Icon from '../components/Icon.astro';
}
.hero-secondary {
margin: 1rem 0 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 0.75rem;
}
.try {
display: inline-flex;
align-items: center;
font-weight: 600;
color: var(--accent);
}
.dot {
color: var(--fg-muted);
}
.watch {
display: inline-flex;