feat: ✨ lazily-loaded Gerber viewer demo in the blog, served from R2

Embed KiCad's gerbview WASM in the graphics-to-WebGL blog post as a
click-to-load demo that opens the tiny_tapeout board entirely in-browser.

- GerberDemo.astro: lazy iframe embed + cross-origin-isolation reload guard
  + WebGL2/SharedArrayBuffer feature-detect with a poster fallback
- public/gerber-demo/{index.html,boot.js}: self-contained gerbview boot
  harness (config seed, MEMFS board preload, argv auto-open)
- board/ tiny_tapeout layers + poster.png committed; wasm/ glue JS committed
  (served same-origin — pthread worker can't be cross-origin), while
  gerbview.wasm + kicad-resources.bin are git-ignored and served from
  Cloudflare R2 (assets.pcbjam.com)
- COOP/COEP require-corp scoped to the post + /gerber-demo routes
  (dev: astro.config.mjs + middleware.ts; prod: vercel.json)
- post converted to MDX (@astrojs/mdx) so it can embed the component
- scripts/: sync-demo-wasm.sh, r2-deploy.sh, r2-cors.json

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Viktor Vaczi 2026-06-17 10:28:06 +02:00
commit 57f1f53d26
36 changed files with 242845 additions and 9 deletions

View file

@ -0,0 +1,291 @@
---
/**
* Interactive, lazily-loaded KiCad Gerber viewer embedded in a blog post.
*
* Nothing heavy loads on page view: the ~22 MB (gzipped) WASM is fetched only
* when the reader activates the viewer, which mounts an <iframe> pointing at the
* self-contained boot page (public/gerber-demo/index.html).
*
* The live viewer needs cross-origin isolation (SharedArrayBuffer + pthreads)
* and WebGL2. We feature-detect and degrade gracefully: capable browsers get the
* live viewer; everything else gets a static poster + a short note. COOP/COEP are
* applied to this route (dev: astro.config.mjs; prod: vercel.json); because
* Astro's <ClientRouter/> can deliver this page via a soft SPA swap (which is
* NOT cross-origin isolated), the client script does one guarded reload so the
* route headers take effect.
*/
const poster = "/gerber-demo/poster.png"; // captured screenshot; optional fallback art
const caption =
"KiCad's Gerber viewer (gerbview), compiled to WebAssembly, running the tiny_tapeout demo board entirely in your browser.";
---
<figure class="gerber-demo not-prose" data-state="idle">
<div class="gerber-demo__frame">
<!-- Poster / launch surface. The href is a real fallback: with JS off (or if
we choose to), it opens the standalone demo page. JS intercepts it to
embed inline. -->
<a
class="gerber-demo__launch"
href="/gerber-demo/index.html"
style={`background-image:url(${poster})`}
>
<span class="gerber-demo__play" aria-hidden="true">▶</span>
<span class="gerber-demo__cta">Launch Gerber viewer</span>
<span class="gerber-demo__sub">~22 MB · loads on demand · runs locally in your browser</span>
</a>
<!-- Shown only when the browser can't run the live viewer. -->
<div class="gerber-demo__fallback" hidden>
<p class="gerber-demo__fallback-title">Live demo unavailable in this browser</p>
<p class="gerber-demo__sub">
The in-browser viewer needs a recent desktop <strong>Chrome</strong>,
<strong>Firefox</strong>, or <strong>Safari 16+</strong> (it relies on
SharedArrayBuffer + WebGL2). Here's a screenshot of what it renders.
</p>
</div>
<!-- Status line while the WASM downloads/boots. -->
<div class="gerber-demo__status" hidden>
<span class="gerber-demo__spinner" aria-hidden="true"></span>
<span class="gerber-demo__status-text">Loading the viewer…</span>
</div>
</div>
<figcaption class="gerber-demo__caption">{caption}</figcaption>
</figure>
<script>
const BOOT_SRC = "/gerber-demo/index.html";
const RELOAD_KEY = "gerber-demo-coi-reload";
function hasWebGL2(): boolean {
try {
return !!document.createElement("canvas").getContext("webgl2");
} catch {
return false;
}
}
function launch(root: HTMLElement): void {
if (root.dataset.state === "loading" || root.dataset.state === "ready") return;
root.dataset.state = "loading";
const status = root.querySelector<HTMLElement>(".gerber-demo__status");
if (status) status.hidden = false;
const iframe = document.createElement("iframe");
iframe.src = BOOT_SRC;
iframe.title = "KiCad Gerber viewer";
// Delegate cross-origin isolation to the (same-origin) iframe so it can use
// SharedArrayBuffer / pthreads.
iframe.allow = "cross-origin-isolated; fullscreen";
iframe.setAttribute("allowfullscreen", "");
iframe.className = "gerber-demo__iframe";
// Size via INLINE styles, not the scoped stylesheet: this element is created
// in JS so it lacks Astro's data-astro-cid scope attribute, and the scoped
// `.gerber-demo__iframe` rule would not match it (it would fall back to the
// 300x150 iframe default, shrinking the whole viewer).
iframe.style.cssText =
"position:absolute;inset:0;width:100%;height:100%;border:0;display:block;";
iframe.addEventListener("load", () => {
root.dataset.state = "ready";
if (status) status.hidden = true;
});
const frame = root.querySelector(".gerber-demo__frame");
frame?.appendChild(iframe);
}
function showFallback(root: HTMLElement): void {
root.dataset.state = "unsupported";
const launchEl = root.querySelector<HTMLElement>(".gerber-demo__launch");
const fallback = root.querySelector<HTMLElement>(".gerber-demo__fallback");
// Keep the poster (it's the <a> background); hide the play affordance, show note.
launchEl?.querySelector(".gerber-demo__play")?.setAttribute("hidden", "");
launchEl?.querySelector(".gerber-demo__cta")?.setAttribute("hidden", "");
launchEl?.querySelector(".gerber-demo__sub")?.setAttribute("hidden", "");
if (launchEl) {
launchEl.removeAttribute("href"); // not clickable
launchEl.style.cursor = "default";
}
if (fallback) fallback.hidden = false;
}
function initDemo(): void {
const root = document.querySelector<HTMLElement>(".gerber-demo");
if (!root) return; // page without the demo
const isolated = window.crossOriginIsolated === true;
// Soft SPA navigation lands here NOT cross-origin isolated; force one real
// load so the route's COOP/COEP headers apply. Guard against reload loops.
if (!isolated) {
if (!sessionStorage.getItem(RELOAD_KEY)) {
sessionStorage.setItem(RELOAD_KEY, "1");
location.reload();
return;
}
// Already retried and still not isolated → genuinely unsupported (or no
// headers, e.g. a preview without vercel.json). Fall back.
} else {
sessionStorage.removeItem(RELOAD_KEY);
}
const supported =
isolated && typeof SharedArrayBuffer !== "undefined" && hasWebGL2();
if (!supported) {
showFallback(root);
return;
}
const launchEl = root.querySelector<HTMLElement>(".gerber-demo__launch");
launchEl?.addEventListener("click", (e) => {
e.preventDefault(); // intercept the href; embed inline instead
launch(root);
});
}
// Run on first load and on every SPA navigation (ClientRouter).
document.addEventListener("astro:page-load", initDemo);
</script>
<style>
.gerber-demo {
margin: 2rem 0;
}
.gerber-demo__frame {
position: relative;
width: 100%;
aspect-ratio: 16 / 10;
min-height: 360px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--bg-soft);
}
/* Launch surface (poster + CTA). */
.gerber-demo__launch {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
text-decoration: none;
color: var(--fg);
background-size: cover;
background-position: center;
background-color: var(--bg-soft);
transition: filter var(--dur) var(--ease);
}
.gerber-demo__launch::before {
/* Dim the poster so the CTA reads clearly. */
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
rgba(11, 16, 32, 0.45),
rgba(11, 16, 32, 0.7)
);
}
.gerber-demo__launch:hover {
filter: brightness(1.06);
text-decoration: none;
}
.gerber-demo__play {
position: relative;
display: grid;
place-items: center;
width: 64px;
height: 64px;
border-radius: 999px;
background: var(--brand-600);
color: #fff;
font-size: 1.5rem;
padding-left: 4px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
}
.gerber-demo__cta {
position: relative;
font-weight: 600;
font-size: 1.05rem;
}
.gerber-demo__sub {
position: relative;
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--fg-muted);
text-align: center;
max-width: 32rem;
padding: 0 1rem;
}
.gerber-demo__iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
display: block;
}
.gerber-demo__status {
position: absolute;
left: 50%;
bottom: 1rem;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.5rem;
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--fg);
background: rgba(11, 16, 32, 0.85);
border: 1px solid var(--border);
padding: 0.4rem 0.7rem;
border-radius: var(--radius);
}
.gerber-demo__spinner {
width: 12px;
height: 12px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 999px;
animation: gerber-spin 0.8s linear infinite;
}
@keyframes gerber-spin {
to {
transform: rotate(360deg);
}
}
.gerber-demo__fallback {
position: absolute;
inset: auto 0 0 0;
padding: 1rem 1.25rem;
background: rgba(11, 16, 32, 0.9);
border-top: 1px solid var(--border);
text-align: center;
}
.gerber-demo__fallback-title {
margin: 0 0 0.25rem;
font-weight: 600;
}
.gerber-demo__fallback .gerber-demo__sub {
position: static;
margin: 0 auto;
}
.gerber-demo__caption {
margin-top: 0.75rem;
font-size: 0.85rem;
color: var(--fg-muted);
line-height: 1.5;
}
@media (prefers-reduced-motion: reduce) {
.gerber-demo__spinner {
animation: none;
}
.gerber-demo__launch {
transition: none;
}
}
</style>

View file

@ -4,7 +4,7 @@ import { glob } from 'astro/loaders';
// Content Layer API (Astro 5+). Markdown files live in src/content/blog/.
// NOTE: this config file is src/content.config.ts — NOT src/content/config.ts.
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string(),

View file

@ -1,7 +0,0 @@
---
title: "Porting KiCad's Graphics to WebGL with Claude"
description: "Coming soon."
pubDate: 2026-06-07
---
Coming soon.

View file

@ -0,0 +1,22 @@
---
title: "Porting KiCad's Graphics to WebGL with Claude"
description: "Coming soon."
pubDate: 2026-06-07
---
import GerberDemo from '../../components/GerberDemo.astro';
{/* TODO(author): intro — the gerbview port and the graphics-abstraction-layer → WebGL work. */}
Coming soon.
## See it run: a Gerber viewer in your browser
The viewer below is KiCad's `gerbview` compiled to WebAssembly. Click to load it
(it streams ~22&nbsp;MB on demand) and it renders a real multi-layer board —
the [tiny_tapeout](https://tinytapeout.com/) demo — entirely client-side, with no
server doing the rasterization.
<GerberDemo />
{/* TODO(author): the rest of the article. */}

23
site/src/middleware.ts Normal file
View file

@ -0,0 +1,23 @@
import { defineMiddleware } from 'astro:middleware';
/**
* Dev-only cross-origin isolation for Astro-rendered pages.
*
* Vite's `server.headers` (astro.config.mjs) already cover static assets in
* public/ (the boot page + WASM), but Astro's dev page renderer writes its own
* response headers and drops them — so the blog post itself wouldn't be
* cross-origin isolated, and the embedded Gerber viewer's SharedArrayBuffer
* would be unavailable. Set them here for `npm run dev`.
*
* Production is static (output: 'static'); these headers come from vercel.json
* scoped to the post + /gerber-demo routes, so we no-op outside dev to keep the
* prerender/build output untouched.
*/
export const onRequest = defineMiddleware(async (_context, next) => {
const response = await next();
if (import.meta.env.DEV) {
response.headers.set('Cross-Origin-Opener-Policy', 'same-origin');
response.headers.set('Cross-Origin-Embedder-Policy', 'require-corp');
}
return response;
});