diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8fa14dd80..7b707d96d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,8 +31,24 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
+ - name: Setup Elixir and OTP
+ uses: erlef/setup-beam@v1
+ with:
+ elixir-version: "1.20.4"
+ otp-version: "29.0.6"
+
+ - name: Install evaluator dependencies
+ working-directory: popcorn
+ run: mix deps.get --check-locked
+
- name: Check
run: |
pnpm lint
pnpm typecheck
pnpm build
+
+ - name: Install Chromium
+ run: pnpm exec playwright install --with-deps chromium
+
+ - name: Test homepage examples on a static server
+ run: pnpm test:popcorn
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 615c1f82d..d28f21f22 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -38,6 +38,16 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
+ - name: Setup Elixir and OTP
+ uses: erlef/setup-beam@v1
+ with:
+ elixir-version: "1.20.4"
+ otp-version: "29.0.6"
+
+ - name: Install evaluator dependencies
+ working-directory: popcorn
+ run: mix deps.get --check-locked
+
- name: Build Astro site
run: pnpm run build
diff --git a/.gitignore b/.gitignore
index d868e490d..eff922749 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,9 @@
# build output
dist/
+.llm/
+popcorn/_build/
+popcorn/deps/
+popcorn/public/
# generated types
.astro/
diff --git a/AGENTS.md b/AGENTS.md
index 1f3f55c5c..db673b8bf 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,18 +16,8 @@ fixes; use `pnpm lint` for the equivalent read-only check.
## Deployment
-- GitHub Pages: see `BUNDLE_AVM.md`, `.github/workflows/deploy.yml`, and the
- `coi-serviceworker` setup.
-
-## Popcorn (homepage hero editor)
-
-- AVM bundle lives at `public/bundle.avm` (~7 MB, committed). Rebuild recipe
- in `BUNDLE_AVM.md`.
-- Vite plugin is conditionally registered in `astro.config.ts` based on the
- bundle's presence so dev setups without it still build.
-- The runtime requires `Cross-Origin-Opener-Policy: same-origin` +
- `Cross-Origin-Embedder-Policy: require-corp` headers. On hosts that can't
- set them, the `coi-serviceworker` workaround is used.
+- GitHub Pages and Popcorn setup: see `README.md` and
+ `.github/workflows/deploy.yml`.
## Reveal animation system
diff --git a/BUNDLE_AVM.md b/BUNDLE_AVM.md
deleted file mode 100644
index 4f8a72a12..000000000
--- a/BUNDLE_AVM.md
+++ /dev/null
@@ -1,80 +0,0 @@
-# Popcorn WASM bundle
-
-The homepage hero ships an interactive Elixir editor backed by
-[Software Mansion's Popcorn](https://github.com/software-mansion/popcorn),
-which compiles AtomVM + an Elixir handler module to WebAssembly.
-
-The runtime is loaded from `public/bundle.avm` and served beneath Astro's
-configured base path. The file is ~7 MB and **is committed** to the repo so
-cloning gives you a working interactive demo out of the box.
-
-When `public/bundle.avm` is missing the editor still works: it loads the
-example snippets and shows their precanned outputs, and `astro.config.ts`
-skips registering the Popcorn Vite plugin. Drop a valid `bundle.avm`
-back into `public/` and the live runtime turns on automatically — no
-code changes needed.
-
-## How to regenerate `bundle.avm`
-
-Popcorn v0.3.0 hard-pins **OTP 26.0.2** and **Elixir 1.17.3**. macOS
-ships much newer toolchains, so the safest way to reproduce the build
-is a one-shot Docker container.
-
-1. Clone the Popcorn repository (anywhere on disk):
-
- ```bash
- git clone https://github.com/software-mansion/popcorn.git ~/popcorn
- ```
-
-2. From the elixir-website repo root, run the pinned-toolchain build:
-
- ```bash
- docker run --rm \
- -v "$HOME/popcorn:/popcorn" \
- -v "$PWD/public:/out" \
- -w /popcorn/examples/eval-in-wasm \
- -e MIX_HOME=/tmp/.mix -e HEX_HOME=/tmp/.hex \
- -e LANG=C.UTF-8 -e LC_ALL=C.UTF-8 -e ELIXIR_ERL_OPTIONS="+fnu" \
- erlang:26.0.2-slim bash -lc '
- set -euo pipefail
- apt-get update -qq && apt-get install -y -qq curl unzip git ca-certificates >/dev/null
- curl -fsSL -o /tmp/elixir.zip https://github.com/elixir-lang/elixir/releases/download/v1.17.3/elixir-otp-26.zip
- mkdir -p /opt/elixir && unzip -q /tmp/elixir.zip -d /opt/elixir
- export PATH=/opt/elixir/bin:$PATH
- mix local.hex --force >/dev/null && mix local.rebar --force >/dev/null
- mix deps.get
- mix popcorn.cook
- cp dist/wasm/bundle.avm /out/bundle.avm
- '
- ```
-
-3. The rebuilt `bundle.avm` lands at `public/bundle.avm`. Commit it
- when the upstream Popcorn version changes or the handler module is
- updated.
-
-## How the editor wires up
-
-- The Vite plugin (`@swmansion/popcorn/vite`) is registered
- conditionally in `astro.config.ts` — only when `public/bundle.avm`
- exists. It excludes Popcorn from Vite's dep prebundling (so the
- internal worker iframe import resolves) and serves the bundle with
- the SharedArrayBuffer-required headers in dev/preview.
-- GitHub Pages uses `public/coi-serviceworker.js` to provide the cross-origin
- isolation headers that the static host cannot configure itself.
-- The hero React island (`src/components/home/HeroCodeEditor.tsx`)
- HEAD-probes the base-path-aware bundle URL; if the response is 200, it
- dynamically imports `@swmansion/popcorn` and calls
- `popcorn.call(["eval_elixir", code], { timeoutMs: 10_000 })`. If
- anything fails it falls back to the precanned output for the active
- example.
-
-## Notes
-
-- Popcorn v0.3.0 is described upstream as "early stages — may break";
- the graceful fallback keeps the page demoable if the runtime fails
- to boot for any reason.
-- The handler action name is `eval_elixir`. If you customise the
- example Mix app (see `~/popcorn/examples/eval-in-wasm/lib/eval_in_wasm.ex`),
- keep the action name in sync with `HeroCodeEditor.tsx`.
-- The bundle includes the full Elixir + stdlib + logger BEAM files for
- the WASM target — that's why it weighs ~7 MB.
diff --git a/README.md b/README.md
index 40ea442fd..d012b84c8 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ Static Astro site, content authored in Markdown, deployed with GitHub Pages.
learning resources and version data
- Shiki for code highlighting: prose via the markdown pipeline, the
homepage snippets at build time (`src/utilities/elixir-highlight.ts`)
-- [Popcorn](https://github.com/software-mansion/popcorn) (AtomVM + Elixir
+- [Popcorn](https://github.com/software-mansion/popcorn) (Erlang/OTP + Elixir
in WebAssembly) powering the interactive hero code editor
- Biome for lint and format
- Playwright for smoke, visual and accessibility tests
@@ -24,13 +24,15 @@ Static Astro site, content authored in Markdown, deployed with GitHub Pages.
```bash
pnpm install
-pnpm dev # http://localhost:4321
+mise install --cd popcorn
+mise exec --cd popcorn -- mix deps.get --check-locked
+mise exec elixir@1.20.4-otp-29 erlang@29.0.6 -- pnpm dev
```
-The Popcorn hero editor uses the AVM bundle at `public/bundle.avm`. The
-file is committed (~7 MB) so the editor "just works" after `pnpm install`.
-If it's missing the editor gracefully falls back to pre-canned outputs.
-Regeneration instructions live in [`BUNDLE_AVM.md`](./BUNDLE_AVM.md).
+Use the same Elixir/OTP toolchain for `pnpm build`, `pnpm preview`, and
+`pnpm typecheck`. Popcorn's Vite plugin compiles and packages the evaluator
+in `popcorn/`; no generated runtime binaries are committed. Building its
+Brotli dependency also requires `make` and a C compiler.
## Scripts
@@ -43,6 +45,7 @@ Regeneration instructions live in [`BUNDLE_AVM.md`](./BUNDLE_AVM.md).
| `pnpm lint` | Biome lint only |
| `pnpm typecheck` | `astro check` (TypeScript across `.astro` and `.ts(x)`) |
| `pnpm test` | All Playwright specs |
+| `pnpm test:popcorn` | Built homepage examples |
| `pnpm test:smoke` | Route smoke checks |
| `pnpm test:visual` | Visual regression snapshots |
| `pnpm test:a11y` | axe-core accessibility scan |
@@ -56,10 +59,9 @@ elixir-website/
├── biome.json
├── tsconfig.json
├── package.json
-├── BUNDLE_AVM.md # how to (re)build public/bundle.avm
├── AGENTS.md # conventions for AI assistants
-├── public/ # favicon, OG fallback, robots.txt,
-│ # bundle.avm (Popcorn runtime)
+├── popcorn/ # evaluator, locked dependencies and toolchain
+├── public/ # favicon, OG fallback, robots.txt, isolation worker
├── scripts/ # Deployment and SVG optimization utilities
├── src/
│ ├── assets/ # fonts, icons, logos, images
@@ -180,9 +182,12 @@ GitHub Pages builds the site through `.github/workflows/deploy.yml`. The
workflow builds the canonical `https://elixir-lang.org` site and deploys the
generated `dist` directory.
-The Popcorn WASM hero editor needs cross-origin isolation. Because GitHub
-Pages cannot configure COOP/COEP response headers, production builds load
-`public/coi-serviceworker.js` from `BaseLayout.astro`.
+Publish the whole `dist/` tree, including Popcorn's generated runtime assets.
+The runtime requires cross-origin isolation. On GitHub Pages, the first Run
+registers `public/coi-serviceworker.js` and reloads once, preserving edits and
+the requested execution. The worker persists across visits and requires
+HTTPS (or localhost). Hosts that set `Cross-Origin-Opener-Policy: same-origin`
+and `Cross-Origin-Embedder-Policy: require-corp` need no worker or reload.
Preview builds emit `noindex, nofollow`. Set `PROD=true` only in the official
deployment workflow to allow search engines to index the site.
diff --git a/astro.config.ts b/astro.config.ts
index bd6d9b072..9028b884f 100644
--- a/astro.config.ts
+++ b/astro.config.ts
@@ -1,6 +1,7 @@
import mdx from "@astrojs/mdx";
import react from "@astrojs/react";
import sitemap from "@astrojs/sitemap";
+import { popcorn } from "@swmansion/popcorn/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig, fontProviders } from "astro/config";
import { toAstroRedirects } from "./src/data/redirects";
@@ -50,6 +51,6 @@ export default defineConfig({
},
integrations: [react(), mdx(), sitemap()],
vite: {
- plugins: [tailwindcss()],
+ plugins: [tailwindcss(), popcorn({ rootDir: "./popcorn" })],
},
});
diff --git a/package.json b/package.json
index 734361f6f..77cebe83c 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
"lint": "pnpm exec biome check",
"typecheck": "astro check",
"test": "playwright test",
+ "test:popcorn": "playwright test --config playwright.popcorn.config.ts",
"test:smoke": "playwright test tests/smoke.spec.ts",
"test:visual": "playwright test tests/visual.spec.ts",
"test:a11y": "playwright test tests/a11y.spec.ts",
@@ -27,6 +28,7 @@
"@shikijs/langs": "3.23.0",
"@shikijs/themes": "3.23.0",
"@shikijs/types": "3.23.0",
+ "@swmansion/popcorn": "1.0.2",
"@tailwindcss/typography": "0.5.19",
"@tailwindcss/vite": "4.1.14",
"astro": "5.16.13",
diff --git a/playwright.popcorn.config.ts b/playwright.popcorn.config.ts
new file mode 100644
index 000000000..cc35e542a
--- /dev/null
+++ b/playwright.popcorn.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from "@playwright/test";
+import config from "./playwright.config";
+
+const baseURL = process.env.BASE_URL || "http://127.0.0.1:4322";
+
+export default defineConfig({
+ ...config,
+ testMatch: "popcorn.spec.ts",
+ workers: 2,
+ use: { ...config.use, baseURL },
+ projects: config.projects?.filter((project) =>
+ ["chromium-desktop", "mobile-s"].includes(project.name ?? ""),
+ ),
+ webServer: process.env.BASE_URL
+ ? undefined
+ : {
+ command:
+ "python3 -m http.server 4322 --bind 127.0.0.1 --directory dist",
+ url: baseURL,
+ reuseExistingServer: !process.env.CI,
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f368315c1..4ecbe58f4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -32,6 +32,9 @@ importers:
'@shikijs/types':
specifier: 3.23.0
version: 3.23.0
+ '@swmansion/popcorn':
+ specifier: 1.0.2
+ version: 1.0.2(esbuild@0.25.12)(rollup@4.60.3)(vite@6.4.2(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.30.1)(yaml@2.9.0))
'@tailwindcss/typography':
specifier: 0.5.19
version: 0.5.19(tailwindcss@4.1.14)
@@ -951,6 +954,20 @@ packages:
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
+ '@swmansion/popcorn@1.0.2':
+ resolution: {integrity: sha512-AtXbJ0w72/SDCCXjkUoe9mGslQhyUwm9rGyjdU+InCWYSuwapf3a8p4JTL4T0HHcg+rSVzQ6dQNpRIhDPyvtrw==}
+ peerDependencies:
+ esbuild: '>=0.17.0'
+ rollup: '>=4.0.0'
+ vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0
+ peerDependenciesMeta:
+ esbuild:
+ optional: true
+ rollup:
+ optional: true
+ vite:
+ optional: true
+
'@tailwindcss/node@4.1.14':
resolution: {integrity: sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw==}
@@ -3516,6 +3533,12 @@ snapshots:
'@shikijs/vscode-textmate@10.0.2': {}
+ '@swmansion/popcorn@1.0.2(esbuild@0.25.12)(rollup@4.60.3)(vite@6.4.2(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.30.1)(yaml@2.9.0))':
+ optionalDependencies:
+ esbuild: 0.25.12
+ rollup: 4.60.3
+ vite: 6.4.2(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.30.1)(yaml@2.9.0)
+
'@tailwindcss/node@4.1.14':
dependencies:
'@jridgewell/remapping': 2.3.5
diff --git a/popcorn/.formatter.exs b/popcorn/.formatter.exs
new file mode 100644
index 000000000..728d88368
--- /dev/null
+++ b/popcorn/.formatter.exs
@@ -0,0 +1,3 @@
+[
+ inputs: ["mix.exs", "{config,lib}/**/*.{ex,exs}"]
+]
diff --git a/popcorn/lib/application.ex b/popcorn/lib/application.ex
new file mode 100644
index 000000000..05b02d8ca
--- /dev/null
+++ b/popcorn/lib/application.ex
@@ -0,0 +1,10 @@
+defmodule WebsiteExamples.Application do
+ use Application
+
+ @impl true
+ def start(_type, _args) do
+ # The output pane displays plain text, not terminal escape sequences.
+ Application.put_env(:elixir, :ansi_enabled, false)
+ Supervisor.start_link([WebsiteExamples.Evaluator], strategy: :one_for_one)
+ end
+end
diff --git a/popcorn/lib/evaluator.ex b/popcorn/lib/evaluator.ex
new file mode 100644
index 000000000..a48bcfe00
--- /dev/null
+++ b/popcorn/lib/evaluator.ex
@@ -0,0 +1,39 @@
+defmodule WebsiteExamples.Evaluator do
+ use GenServer
+
+ def start_link(_args), do: GenServer.start_link(__MODULE__, nil, name: :evaluator)
+
+ @impl true
+ def init(_args), do: {:ok, nil}
+
+ @impl true
+ def handle_call(["eval", source], _from, state) do
+ task = Task.async(fn -> evaluate(source) end)
+
+ result =
+ case Task.yield(task, 10_000) || Task.shutdown(task, :brutal_kill) do
+ {:ok, result} -> result
+ nil -> %{error: "Execution timed out after 10 seconds."}
+ end
+
+ {:reply, result, state}
+ end
+
+ defp evaluate(source) do
+ {value, _bindings} = Code.eval_string(source, [], file: "example.exs")
+ %{value: inspect(value, pretty: true, width: 50)}
+ rescue
+ error -> %{error: Exception.format(:error, error, user_stacktrace(__STACKTRACE__))}
+ catch
+ kind, reason -> %{error: Exception.format(kind, reason, user_stacktrace(__STACKTRACE__))}
+ end
+
+ defp user_stacktrace(stacktrace) do
+ Enum.take_while(stacktrace, fn
+ {:elixir, _, _, _} -> false
+ {Code, :validated_eval_string, _, _} -> false
+ {__MODULE__, _, _, _} -> false
+ _ -> true
+ end)
+ end
+end
diff --git a/popcorn/mise.toml b/popcorn/mise.toml
new file mode 100644
index 000000000..f3c4ae04d
--- /dev/null
+++ b/popcorn/mise.toml
@@ -0,0 +1,3 @@
+[tools]
+elixir = "1.20.4-otp-29"
+erlang = "29.0.6"
diff --git a/popcorn/mix.exs b/popcorn/mix.exs
new file mode 100644
index 000000000..0d48ba46c
--- /dev/null
+++ b/popcorn/mix.exs
@@ -0,0 +1,16 @@
+defmodule WebsiteExamples.MixProject do
+ use Mix.Project
+
+ def project do
+ [
+ app: :website_examples,
+ version: "0.1.0",
+ elixir: "~> 1.20",
+ deps: [{:popcorn, "== 1.0.1"}]
+ ]
+ end
+
+ def application do
+ [mod: {WebsiteExamples.Application, []}]
+ end
+end
diff --git a/popcorn/mix.lock b/popcorn/mix.lock
new file mode 100644
index 000000000..4dc2c73cf
--- /dev/null
+++ b/popcorn/mix.lock
@@ -0,0 +1,5 @@
+%{
+ "brotli": {:hex, :brotli, "0.3.4", "da8b4486b55ac937fc8c24142a25f740e13681a7c3702e82ded9123a9c2e0afd", [:rebar3], [], "hexpm", "1439bf0d635c7818cb531054ddbbf2e223d49a79350b7ace26a2965ae0aecb25"},
+ "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
+ "popcorn": {:hex, :popcorn, "1.0.1", "4ca654138b9210b56676b4f903772293b2fe981ded9d6e2e761e55c712365ab7", [:mix], [{:brotli, "~> 0.3.3", [hex: :brotli, repo: "hexpm", optional: false]}, {:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:req, "~> 0.5.0 or ~> 0.6.0 or ~> 0.7.0 or 0.8.0-rc.0", [hex: :req, repo: "hexpm", optional: true]}], "hexpm", "d17914bf6394684096bf1939fdc6d5008ab06597616f694b4e10464868ba62b0"},
+}
diff --git a/public/coi-serviceworker.js b/public/coi-serviceworker.js
index 97aef7013..b754e023f 100644
--- a/public/coi-serviceworker.js
+++ b/public/coi-serviceworker.js
@@ -1,7 +1,9 @@
/*! coi-serviceworker v0.1.7 - Guido Zuidhof and contributors, licensed under MIT */
let coepCredentialless = false;
if (typeof window === "undefined") {
- self.addEventListener("install", () => self.skipWaiting());
+ self.addEventListener("install", (event) =>
+ event.waitUntil(self.skipWaiting()),
+ );
self.addEventListener("activate", (event) =>
event.waitUntil(self.clients.claim()),
);
@@ -60,7 +62,7 @@ if (typeof window === "undefined") {
headers: newHeaders,
});
})
- .catch((e) => console.error(e)),
+ .catch(() => Response.error()),
);
});
} else {
diff --git a/src/assets/icons/popcorn.svg b/src/assets/icons/popcorn.svg
new file mode 100644
index 000000000..0c1c673dd
--- /dev/null
+++ b/src/assets/icons/popcorn.svg
@@ -0,0 +1,8 @@
+
diff --git a/src/components/home/Hero.astro b/src/components/home/Hero.astro
index a0495df28..353d28dcc 100644
--- a/src/components/home/Hero.astro
+++ b/src/components/home/Hero.astro
@@ -8,9 +8,7 @@ import {
highlightElixir,
} from "@/utilities/elixir-highlight";
-// The editor island highlights the pristine examples from this
-// build-time output; shiki is only loaded in the browser once the
-// user edits the code.
+// Highlight pristine examples at build time; editing uses a native textarea.
const highlighter = await getElixirHighlighter();
const highlightedExamples = EXAMPLES.map((example) =>
highlightElixir(highlighter, example.code),
diff --git a/src/components/home/HeroCodeEditor.tsx b/src/components/home/HeroCodeEditor.tsx
index 79840a6a8..452740c73 100644
--- a/src/components/home/HeroCodeEditor.tsx
+++ b/src/components/home/HeroCodeEditor.tsx
@@ -1,7 +1,9 @@
-import { Fragment, useState } from "react";
+import popcornLogo from "@/assets/icons/popcorn.svg?url";
import { EXAMPLES } from "@/components/home/hero-examples";
+import { useExampleEditor } from "@/components/home/use-example-editor";
+import { useResultAnimation } from "@/components/home/use-result-animation";
import type { HighlightedLine } from "@/utilities/elixir-highlight";
-import { tokenStyle } from "@/utilities/highlight-style";
+import HighlightedCode from "./HighlightedCode";
function ArrowIcon({ direction = "right" }: { direction?: "left" | "right" }) {
return (
@@ -25,15 +27,13 @@ function ArrowIcon({ direction = "right" }: { direction?: "left" | "right" }) {
}
function ExampleDots({
- count,
activeIdx,
onSelect,
- titles,
+ disabled,
}: {
- count: number;
activeIdx: number;
onSelect: (i: number) => void;
- titles: string[];
+ disabled: boolean;
}) {
return (
- {titles.slice(0, count).map((title, i) => {
+ {EXAMPLES.map(({ title }, i) => {
const active = i === activeIdx;
+ let background = "bg-gray-60 hover:bg-gray-40";
+ if (active) {
+ background = "bg-white-10";
+ }
return (